[
  {
    "path": ".codecov.yml",
    "content": "codecov:\n  require_ci_to_pass: false\n\ncomment:\n  layout: \"diff, flags, files\"\n  behavior: default\n  require_changes: false  # if true: only post the comment if coverage changes\n\ncoverage:\n  status:\n    project:\n      default:\n        target: auto\n        threshold: 1%\n    patch:\n      default:\n        target: auto\n        threshold: 1%\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: caronc\ncustom:\n  - 'https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E'\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/1_bug_report.md",
    "content": "---\nname: 🐛 Bug Report\nabout: Report errors and problems in Apprise\ntitle: ''\nlabels: 'bug'\nassignees: ''\n\n---\n\n## Notification Service(s) Impacted\n<!-- Example: Discord, Telegram, Pushbullet, etc -->\n<!-- If unknown, write \"Unknown\" and include your failing URL schema below -->\n\n## What happened\n<!-- Describe the bug and what you expected to happen -->\n\n## Apprise URL(s) involved (redact secrets)\n<!-- Include schema and structure, redact tokens/passwords -->\n<!-- Example: discord://webhook_id/webhook_token -->\n<!-- Example: mailto://user:****@example.com?to=a@example.com -->\n\n## Steps to reproduce\n1.\n2.\n3.\n\n## Environment\n- Apprise version: <!-- apprise --version OR pip show apprise -->\n- Python version: <!-- python --version -->\n- OS and distribution: <!-- e.g. Ubuntu 24.04, Rocky Linux 9, Windows 11 -->\n- Install method: <!-- pip, distro package, docker, source -->\n- If using Docker: image/tag:\n\n## Logs (redact secrets)\n<!-- Include -vv output if possible -->\n```text\npaste logs here\n```\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/2_enhancement_request.md",
    "content": "---\nname: 💡 Enhancement Request\nabout: Suggest an improvement to Apprise\ntitle: ''\nlabels: 'enhancement'\nassignees: ''\n\n---\n\n## The idea\n<!-- What should Apprise do, and why is it valuable? -->\n\n## Use-case\n<!-- Real-world scenario, who benefits, and how -->\n\n## Proposed change\n<!-- API, CLI, URL format, configuration, behaviours -->\n\n## Compatibility impact\n- Would this be a breaking change? Yes / No\n- If yes, describe what breaks and any migration path.\n\n## Alternatives considered\n<!-- Any other approaches you considered -->\n\n## Documentation impact\n- Does appriseit.com require updates for this change? Yes / No\n- If yes, please also open (or link) a documentation ticket/PR in apprise-docs.\n\n## Additional context\n<!-- Links, screenshots, logs, references -->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/3_new-notification-request.md",
    "content": "---\nname: 📣 New Notification Request\nabout: Request a new notification service integration\ntitle: ''\nlabels: ['enhancement', 'new-notification']\nassignees: ''\n\n---\n\n## What is the name of the service?\n<!-- Name of Service -->\n\n## Proposed Apprise schema (service id)\n<!-- Example: foo:// or foos:// -->\n<!-- If unsure, leave blank and we will suggest one -->\n\n## Proposed Appriseit service slug\n<!-- Example: foo (maps to https://appriseit.com/services/foo/) -->\n<!-- If unsure, leave blank and we will suggest one -->\n\n## Provide details that help development\n- Homepage:\n- Official API docs:\n- Authentication method: <!-- API key, OAuth, webhook, etc -->\n- Rate limits (if known):\n- Message limits (if known): <!-- title/body length, attachments, etc -->\n- Attachments supported: Yes / No / Unknown\n\n## Example payload or curl snippet (optional)\n<!-- Redact secrets -->\n```text\npaste example here\n```\n\n## Anything else?\n<!-- Features you would like supported, URL parameters, batching, attachments, etc -->\n\n## ☝️ Documentation note\nIf this integration is accepted, it must also include an apprise-docs update so the service page exists on appriseit.com.\nIf you can contribute docs, open a ticket or PR in: https://github.com/caronc/apprise-docs\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/4_question.md",
    "content": "---\nname: ❓ Support Question\nabout: Ask a question about Apprise (prefer Discussions)\ntitle: ''\nlabels: 'question'\nassignees: ''\n\n---\n\n## Please use Discussions for support questions\nhttps://github.com/caronc/apprise/discussions\n\nIf you are filing an issue anyway, include:\n\n## Question\n<!-- Ask your question here -->\n\n## Apprise version and environment\n- Apprise version:\n- Python version:\n- OS and distribution:\n- Install method:\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yaml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Documentation (Apprise Docs)\n    url: https://appriseit.com\n    about: Documentation\n  - name: Documentation Source (Apprise Docs)\n    url: https://github.com/caronc/apprise-docs\n    about: Documentation updates, fixes, and translation work belong here.\n  - name: Support and Questions\n    url: https://github.com/caronc/apprise/discussions\n    about: Please use Discussions for questions and general support.\n\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Description\n**Related issue (if applicable):** #<!-- apprise issue number goes here -->\n\n<!--\n  -- Have anything else to describe?\n  -- Define it here; this helps build the documentation site later\n-->\n\n<!-- START OF NEW PLUGIN SECTION\n  -- Delete this section if you are not creating a new plugin --\n\n## *ServiceName* Notifications\n* **Source**: https://official.website.example.ca\n* **Image Support**: Yes / No\n* **Message Format**: Plain Text / HTML / Markdown\n* **Message Limit**: nn characters\n\nDescribe your service here.\n\n## Account Setup\n1. Visit the service portal and sign in using your account credentials.\n2. Generate and copy your token, key, or credentials.\n\n## Syntax\n\nValid syntax is as follows:\n- `service://{variable}`\n\n## Parameter Breakdown\n\n| Variable  | Required |  Description   |\n|-----------|----------|----------------|\n| variable1 | Yes      | Your variable1 |\n| variable2 | No       | Your variable2 |\n\n## Examples\n\nSends a simple example:\n```bash\napprise -vv -t \"Title\" -b \"Message content\" \\\n    service://token\n```\n\n## New Service Completion Status\n* [ ] apprise/plugins/--new_plugin_name.py\n* [ ] pyproject.toml\n    - Update keywords section to identify the new service (alphabetically).\n* [ ] README.md\n    - Add entry for the new service (quick reference only).\n* [ ] packaging/redhat/python-apprise.spec\n    - add new service into the `%global common_description`\n\nEND OF NEW PLUGIN SECTION -->\n\n<!-- The following must be completed or your PR cannot be merged -->\n## Checklist\n* [ ] Documentation ticket created (if applicable):** [apprise-docs/##](https://github.com/caronc/apprise-docs/issue/<!--apprise-docs issue number goes here-->)\n* [ ] The change is tested and works locally.\n* [ ] No commented-out code in this PR.\n* [ ] No lint errors (use `tox -e lint` and optionally `tox -e format`).\n* [ ] Test coverage added or updated (use `tox -e minimal`).\n\n## Testing\n<!-- If your change is testable by others, define how to validate it here -->\nAnyone can help test as follows:\n```bash\n# Create a virtual environment\npython3 -m venv apprise\n\n# Change into our new directory\ncd apprise\n\n# Activate our virtual environment\nsource bin/activate\n\n# Install the branch\npip install git+https://github.com/caronc/apprise.git@<this.branch-name>\n\n# If you have cloned the branch and have tox available to you:\ntox -e apprise -- -t \"Test Title\" -b \"Test Message\" \\\n    <apprise url related to this change>\n```\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "name: \"CodeQL\"\n\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\n  schedule:\n    - cron: '42 15 * * 5'\n\n# Cancel in-progress jobs when pushing to the same branch.\nconcurrency:\n  cancel-in-progress: true\n  group: ${{ github.workflow }}-${{ github.ref }}\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: [ 'python' ]\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v4\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\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v3\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v3\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "# .github/workflows/lint.yml\nname: Run Lint Checks\n\non:\n  push:\n    paths:\n      - '**.py'\n  pull_request:\n    paths:\n      - '**.py'\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Set up Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n\n      - name: Install tox\n        run: python -m pip install tox\n\n      - name: Run Ruff lint check\n        run: tox -e lint\n"
  },
  {
    "path": ".github/workflows/loc-badge.yml",
    "content": "# LoC = Lines of Code\nname: LoC Badge\n\non:\n  # Manual only\n  workflow_dispatch:\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  loc-badge:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Generate LoC badge (Apprise library only)\n        uses: alexispurslane/GHA-LoC-Badge@v2.0.0\n\n        id: badge\n        with:\n          debug: true\n          # Run from the root of the repo so the pattern paths are exact\n          directory: ./\n          badge: .github/badges/loc.svg\n          patterns: \"apprise/**/*.py\"\n          ignore: \"__pycache__\"\n\n      - name: Print the output\n        run: |\n          echo \"Scanned: ${{ steps.badge.outputs.counted_files }}\";\n          echo \"Line Count: ${{ steps.badge.outputs.total_lines }}\";\n\n      - name: Create PR (if changed)\n        uses: peter-evans/create-pull-request@v7\n        with:\n          commit-message: \"Update Lines of Code (LoC) badge\"\n          title: \"Update LoC badge\"\n          body: \"Automated update of the Lines of Code badge.\"\n          branch: \"automation/loc-badge\"\n          delete-branch: true\n          add-paths: \".github/badges/loc.svg\"\n"
  },
  {
    "path": ".github/workflows/pkgbuild.yml",
    "content": "#\n# Verify on CI/GHA that RPM package building works.\n#\nname: RPM Packaging\n\non:\n  push:\n    branches: [main, master, 'release/**']\n  pull_request:\n    branches: [main, master]\n  workflow_dispatch:\n\njobs:\n\n  build:\n    name: Build RPMs (${{ matrix.dist }})\n    runs-on: ubuntu-latest\n\n    strategy:\n      fail-fast: false\n      matrix:\n        dist: [el9, el10]\n\n    container:\n      image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }}\n      options: --user root\n\n    steps:\n      - name: Checkout source\n        uses: actions/checkout@v4\n\n      - name: Build RPMs\n        run: ./bin/build-rpm.sh\n        env:\n          APPRISE_DIR: ${{ github.workspace }}\n          # Drop RPMs into dist/<dist> inside the workspace\n          DIST_DIR: ${{ github.workspace }}/dist/${{ matrix.dist }}\n\n      - name: Show RPMs found for upload\n        run: |\n          echo \"Listing dist/${{ matrix.dist }}/**/*.rpm:\"\n          find dist/${{ matrix.dist }} -type f -name '*.rpm'\n\n      - name: Upload RPM Artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: built-rpms-${{ matrix.dist }}\n          path: ./dist/${{ matrix.dist }}\n          if-no-files-found: error\n          retention-days: 5\n\n      - name: Upload rpmlint config files\n        uses: actions/upload-artifact@v4\n        with:\n          # Upload the specific files needed for verification\n          name: rpmlint-config-${{ matrix.dist }}\n          path: ./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }}\n          retention-days: 5\n\n  verify:\n    name: Verify RPMs (${{ matrix.dist }})\n    needs: build\n    runs-on: ubuntu-latest\n\n    strategy:\n      fail-fast: false\n      matrix:\n        dist: [el9, el10]\n\n    container:\n      image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }}\n      options: --user root\n\n    steps:\n      - name: Download built RPMs\n        uses: actions/download-artifact@v4\n        with:\n          name: built-rpms-${{ matrix.dist }}\n          path: ./dist\n\n      - name: Download rpmlint config files\n        uses: actions/download-artifact@v4\n        with:\n          name: rpmlint-config-${{ matrix.dist }}\n          # Download files directly into the correct directory\n          path: ./packaging/redhat\n\n      - name: Lint RPMs\n        run: |\n          set -e\n          RC_FILE=\"./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }}\"\n\n          if rpmlint --help 2>&1 | grep -q -- '--rpmlintrc'; then\n            echo \"Using rpmlint --rpmlintrc with $RC_FILE\"\n            rpmlint --rpmlintrc \"$RC_FILE\" ./dist/**/*.rpm\n          else\n            echo \"Using rpmlint v1.x on older distribution\"\n            rpmlint -f \"$RC_FILE\" ./dist/**/*.rpm\n          fi\n\n      - name: Install and verify RPMs\n        run: |\n          echo \"Installing RPMs from: ./dist/\"\n          find ./dist -name '*.rpm'\n          dnf install -y ./dist/**/*.rpm\n          apprise --version\n\n      - name: Check Installed Files\n        run: rpm -qlp ./dist/**/*.rpm\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Run Tests\n\non:\n  # Run tests on push to main, master, or any release/ branch\n  push:\n    branches: [main, master, 'release/**']\n  # Always test on pull requests targeting main/master\n  pull_request:\n    branches: [main, master]\n  # Allow manual triggering via GitHub UI\n  workflow_dispatch:\n\njobs:\n  test:\n    name: Python ${{ matrix.python-version }} – ${{ matrix.tox_env }} on ${{ matrix.os }}\n    runs-on: ${{ matrix.os || 'ubuntu-latest' }}\n\n    strategy:\n      fail-fast: false  # Let all jobs run, even if one fails\n      matrix:\n        include:\n          - python-version: \"3.9\"\n            tox_env: qa\n          - python-version: \"3.10\"\n            tox_env: qa\n          - python-version: \"3.11\"\n            tox_env: qa\n          - python-version: \"3.12\"\n            tox_env: qa\n\n          # Pre-release testing (won't fail entire workflow if this fails)\n          - python-version: \"3.13-dev\"\n            tox_env: qa\n            continue-on-error: true\n\n          - python-version: \"3.14-dev\"\n            tox_env: qa\n            continue-on-error: true\n\n          - python-version: \"3.15-dev\"\n            tox_env: qa\n            continue-on-error: true\n\n          # Platform validation only (one version)\n          - os: windows-latest\n            python-version: \"3.12\"\n            tox_env: qa\n\n          - os: macos-latest\n            python-version: \"3.12\"\n            tox_env: qa\n\n          # Minimal test run on latest Python only\n          # this verifies Apprise still works when extra libraries are not available\n          - python-version: \"3.12\"\n            tox_env: minimal\n\n    steps:\n      - uses: actions/checkout@v4\n\n      # Install tox for isolated environment and plugin test orchestration\n      - name: Install tox\n        run: python -m pip install tox\n\n      # Run tox with the specified environment (qa, minimal, etc.)\n      - name: Run tox for ${{ matrix.tox_env }}\n        run: tox -e ${{ matrix.tox_env }}\n\n      - name: Upload coverage report\n        if: always()\n        uses: actions/upload-artifact@v4\n        with:\n          name: coverage-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.tox_env }}\n          path: .coverage\n          include-hidden-files: true\n\n  codecov:\n    name: Upload merged coverage to Codecov\n    runs-on: ubuntu-latest\n    needs: test  # Waits for all matrix jobs to complete\n    if: always()  # Even if a test fails, still attempt to upload what we have\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Download all coverage reports\n        uses: actions/download-artifact@v4\n        with:\n          path: coverage-artifacts\n\n      - name: Combine and generate coverage\n        run: |\n          pip install coverage\n      \n          # Create a consistent temp dir\n          mkdir -p coverage-inputs\n      \n          # Copy and rename each coverage file to .coverage.job_name\n          i=0\n          for f in $(find coverage-artifacts -name .coverage); do\n            cp \"$f\" \"coverage-inputs/.coverage.$i\"\n            i=$((i+1))\n          done\n      \n          # Confirm files staged\n          ls -alh coverage-inputs\n      \n          # Combine them all\n          coverage combine coverage-inputs\n          coverage report\n          coverage xml -o coverage.xml\n\n      # Upload merged coverage results to Codecov for visualization\n      - name: Upload to Codecov\n        uses: codecov/codecov-action@v4\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          file: coverage.xml\n          verbose: false  # Used for debugging only\n          fail_ci_if_error: false  # Avoid failing job if Codecov is down\n        env:\n          CODECOV_PR:  ${{ github.event.pull_request.number }}\n          CODECOV_SHA: ${{ github.sha }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# left-over-files from conflicts/merges\n*.orig\n\n# C extensions\n*.so\n\n# vi swap files\n.*.sw?\n\n# Distribution / packaging\n.Python\nenv/\n.venv*\nbuild/\nBUILD/\nBUILDROOT/\nSOURCES/\nSRPMS/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib64/\nparts/\nsdist/\n*.egg-info/\n.installed.cfg\n*.egg\n.local\n\n# Generated from Docker Instance\n.bash_history\n.python_history\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*,cover\n.hypothesis/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n#Ipython Notebook\n.ipynb_checkpoints\n\n#PyCharm\n.idea\n\n#PyDev (Eclipse)\n.project\n.pydevproject\n.settings\n\n# Others\n.DS_Store\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"python.testing.pytestArgs\": [\n    \"tests\"\n  ],\n  \"python.linting.enabled\": true,\n  \"python.linting.ruffEnabled\": true,\n  \"python.linting.ruffPath\": \"ruff\",\n  \"python.formatting.provider\": \"none\",\n  \"editor.formatOnSave\": true,\n  \"python.testing.cwd\": \"${workspaceFolder}\",\n  \"python.testing.unittestEnabled\": false,\n  \"python.testing.pytestEnabled\": true,\n  \"python.envFile\": \"${workspaceFolder}/.env\",\n  \"terminal.integrated.env.linux\": {\n    \"PYTHONPATH\": \"${workspaceFolder}\"\n  }\n}"
  },
  {
    "path": "ACKNOWLEDGEMENTS.md",
    "content": "# Contributions to the Apprise Project\n\n## Creator & Maintainer\n\n* Chris Caron <lead2gold@gmail.com>\n\n## Contributors\n\nThe following users have contributed to this project and their deserved\nrecognition has been identified here.  If you have contributed and wish\nto be acknowledged for it, the syntax is as follows:\n\n```\n* [Your name or handle] <[email or website]>\n  * [Month Year] - [Brief summary of your contribution]\n```\n\nThe contributors have been listed in chronological order:\n* Wim de With <wf@dewith.io>\n  * Dec 2018 - Added Matrix Support\n\n* Hitesh Sondhi <hitesh@cropsly.com>\n  * Mar 2019 - Added Flock Support\n\n* Andreas Motl <andreas.motl@panodata.org>\n  * Mar 2020 - Fix XMPP Support\n  * Oct 2022 - Drop support for Python 2\n  * Oct 2022 - Add support for Python 3.11\n  * Oct 2022 - Improve efficiency of NotifyEmail\n\n* Joey Espinosa <@particledecay>\n  * Apr 3rd 2022 - Added Ntfy Support\n\n* Kate Ward <https://kate.pet>\n  * 6th Feb 2024 - Add Revolt Support\n\n* Han Wang <freddie.wanah@gmail.com>\n  * Apr 2024 - Refactored test cases\n\n* Toni Wells <@isometimescode>\n  * May 2024 - Fixed token length with apprise://\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# 🤝 Contributing to Apprise\n\nThank you for your interest in contributing to Apprise!\n\nWe welcome bug reports, feature requests, documentation improvements, and new\nnotification plugins. Please follow the guidelines below to help us review and\nmerge your contributions smoothly.\n\n---\n\n## ✅ Quick Checklist Before You Submit\n\n- ✔️ Your code passes all lint checks:\n  ```bash\n  tox -e lint\n  ```\n\n- ✔️ Your changes are covered by tests:\n  ```bash\n  tox -e qa\n  ```\n\n- ✔️ Your code is clean and consistently formatted:\n  ```bash\n  tox -e format\n  ```\n\n\n- ✔️ You followed the plugin template (if adding a new plugin).\n- ✔️ You included inline docstrings and respected the BSD 2-Clause license.\n- ✔️ Your commit message is descriptive.\n\n---\n\n## 📦 Local Development Setup\n\nTo get started with development:\n\n### 🧰 System Requirements\n\n- Python >= 3.9\n- `pip`\n- `git`\n- Optional: `VS Code` with the Python extension\n\n### 🚀 One-Time Setup\n\n```bash\ngit clone https://github.com/caronc/apprise.git\ncd apprise\n\n# Install all runtime + dev dependencies\npip install .[dev]\n```\n\n(Optional, but recommended if actively developing):\n```bash\npip install -e .[dev]\n```\n\n---\n\n## 🧪 Running Tests\n\n```bash\npytest               # Run all tests\npytest tests/foo.py  # Run a specific test file\n```\n\nRun with coverage:\n```bash\npytest --cov=apprise --cov-report=term\n```\n\n---\n\n## 🧹 Linting & Formatting with ruff\n\n```bash\nruff check apprise tests           # Check linting\nruff check apprise tests --fix     # Auto-fix\nruff format apprise tests          # Format files\n```\n\n---\n\n## 🧰 Optional: Using VS Code\n\n1. Open the repo: `code .`\n2. Press `Ctrl+Shift+P → Python: Select Interpreter`\n3. Choose the same interpreter you used for `pip install .[dev]`\n4. Press `Ctrl+Shift+P → Python: Discover Tests`\n\n`.vscode/settings.json` is pre-configured with:\n\n- pytest as the test runner\n- ruff for linting\n- PYTHONPATH set to project root\n\nNo `.venv` is required unless you choose to use one.\n\n---\n\n## 📌 How to Contribute\n\n1. **Fork the repository** and create a new branch.\n2. Make your changes.\n3. Run the checks listed above.\n4. Submit a pull request (PR) to the `main` branch.\n\nGitHub Actions will run tests and lint checks on your PR automatically.\n\n---\n\n## 🧪 Need Help with Testing or Plugins?\n\nSee [DEVELOPMENT.md](./DEVELOPMENT.md) for:\n- Full setup instructions\n- Tox environment descriptions\n- RPM testing\n- Plugin development guidance\n\n---\n\n## 🙏 Thank You\n\nYour contributions make Apprise better for everyone — thank you!\n\n📝 See [ACKNOWLEDGEMENTS.md](./ACKNOWLEDGEMENTS.md) for a list of contributors.\n"
  },
  {
    "path": "LICENSE",
    "content": "BSD 2-Clause License\n\nCopyright (c) 2026, Chris Caron <lead2gold@gmail.com>\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n   list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n   this list of conditions and the following disclaimer in the documentation\n   and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include LICENSE\ninclude README.md\ninclude CONTRIBUTING.md\ninclude ACKNOWLEDGEMENTS.md\ninclude SECURITY.md\ninclude pyproject.toml\ninclude tox.ini\ninclude babel.cfg\ninclude requirements.txt\ninclude win-requirements.txt\ninclude dev-requirements.txt\ninclude all-plugin-requirements.txt\ninclude apprise/py.typed\nrecursive-include tests *\nrecursive-include packaging *\nrecursive-include apprise/i18n *.pot\nrecursive-include apprise/i18n *.po\nrecursive-include apprise/i18n */LC_MESSAGES/*.po\nglobal-exclude *.pyc\nglobal-exclude *.pyo\nglobal-exclude __pycache__\n"
  },
  {
    "path": "README.md",
    "content": "![Apprise Logo](https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png)\n\n<hr/>\n\n**ap·prise** / *verb*<br/>\nTo inform or tell (someone). To make one aware of something.\n<hr/>\n\n*Apprise* allows you to send a notification to *almost* all of the most popular *notification* services available to us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc.\n\n* One notification library to rule them all.\n* A common and intuitive notification syntax.\n* Supports the handling of images and attachments (_to the notification services that will accept them_).\n* It's incredibly lightweight.\n* Amazing response times because all messages sent asynchronously.\n\nDevelopers who wish to provide a notification service no longer need to research each and every one out there. They no longer need to try to adapt to the new ones that comeout thereafter. They just need to include this one library and then they can immediately gain access to almost all of the notifications services available to us today.\n\nSystem Administrators and DevOps who wish to send a notification now no longer need to find the right tool for the job. Everything is already wrapped and supported within the `apprise` command line tool (CLI) that ships with this product.\n\n[![Paypal](https://img.shields.io/badge/paypal-donate-green.svg)](https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E)\n[![Follow](https://img.shields.io/twitter/follow/l2gnux)](https://twitter.com/l2gnux/)<br/>\n[![Discord](https://img.shields.io/discord/558793703356104724.svg?colorB=7289DA&label=Discord&logo=Discord&logoColor=7289DA&style=flat-square)](https://discord.gg/MMPeN2D)\n[![Python](https://img.shields.io/pypi/pyversions/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/)\n[![Build Status](https://github.com/caronc/apprise/actions/workflows/tests.yml/badge.svg)](https://github.com/caronc/apprise/actions/workflows/tests.yml)\n[![Lines of Code](https://raw.githubusercontent.com/caronc/apprise/master/.github/badges/loc.svg)](https://github.com/caronc/apprise/actions/workflows/loc-badge.yml)\n[![CodeCov Status](https://codecov.io/github/caronc/apprise/branch/master/graph/badge.svg)](https://codecov.io/github/caronc/apprise)\n[![PyPi Downloads](https://img.shields.io/pepy/dt/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/)\n\n# Table of Contents\n<!--ts-->\n* [Supported Notifications](#supported-notifications)\n  * [Productivity Based Notifications](#productivity-based-notifications)\n  * [SMS Notifications](#sms-notifications)\n  * [Desktop Notifications](#desktop-notifications)\n  * [Email Notifications](#email-notifications)\n  * [Custom Notifications](#custom-notifications)\n* [Installation](#installation)\n* [Command Line Usage](#command-line-usage)\n  * [Configuration Files](#cli-configuration-files)\n  * [File Attachments](#cli-file-attachments)\n  * [Loading Custom Notifications/Hooks](#cli-loading-custom-notificationshooks)\n  * [Environment Variables](#cli-environment-variables)\n* [Developer API Usage](#developer-api-usage)\n  * [Configuration Files](#api-configuration-files)\n  * [File Attachments](#api-file-attachments)\n  * [Loading Custom Notifications/Hooks](#api-loading-custom-notificationshooks)\n* [Persistent Storage](#persistent-storage)\n* [More Supported Links and Documentation](#want-to-learn-more)\n<!--te-->\n\nVisit the [Official Documentation](https://appriseit.com/getting-started/) site for more information on Apprise.\n\n# Supported Notifications\n\nThe section identifies all of the services supported by this library. [Check out the wiki for more information on the supported modules here](https://appriseit.com/).\n\n## Productivity Based Notifications\n\nThe table below identifies the services this tool supports and some example service urls you need to use in order to take advantage of it. Click on any of the services listed below to get more details on how you can configure Apprise to access them. If you're having trouble constructing your own URL; try our [Apprise URL Builder](https://appriseit.com/tools/url-builder/) out.\n\n| Notification Service | Service ID | Default Port | Example Syntax |\n| -------------------- | ---------- | ------------ | -------------- |\n| [Apprise API](https://appriseit.com/services/apprise_api/)  | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token\n| [AWS SES](https://appriseit.com/services/ses/)  | ses://   | (TCP) 443   | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName<br/>ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN\n| [Bark](https://appriseit.com/services/bark/)  | bark://   | (TCP) 80 or 443   | bark://hostname<br />bark://hostname/device_key<br />bark://hostname/device_key1/device_key2/device_keyN<br/>barks://hostname<br />barks://hostname/device_key<br />barks://hostname/device_key1/device_key2/device_keyN\n| [BlueSky](https://appriseit.com/services/bluesky/) | bluesky://  | (TCP) 443   | bluesky://Handle:AppPw<br />bluesky://Handle:AppPw/TargetHandle<br />bluesky://Handle:AppPw/TargetHandle1/TargetHandle2/TargetHandleN\n| [Brevo](https://appriseit.com/services/brevo/) | brevo://  | (TCP) 443   | brevo://APIToken:FromEmail/<br />brevo://APIToken:FromEmail/ToEmail<br />brevo://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/\n| [Chanify](https://appriseit.com/services/chanify/) | chantify://    | (TCP) 443    | chantify://token\n| [Discord](https://appriseit.com/services/discord/)  | discord://   | (TCP) 443   | discord://webhook_id/webhook_token<br />discord://avatar@webhook_id/webhook_token\n| [Dot.](https://appriseit.com/services/dot/)  | dot:// | (TCP) 443 | dot://apikey@device_id/text/<br />dot://apikey@device_id/image/<br />**Note**: `device_id` is the Quote/0 hardware serial\n| [Emby](https://appriseit.com/services/emby/)  | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/<br />emby://user:password@hostname\n| [Enigma2](https://appriseit.com/services/enigma2/)  | enigma2:// or enigma2s:// | (TCP) 80 or 443 | enigma2://hostname\n| [FCM](https://appriseit.com/services/fcm/) | fcm://    | (TCP) 443    | fcm://project@apikey/DEVICE_ID<br />fcm://project@apikey/#TOPIC<br/>fcm://project@apikey/DEVICE_ID1/#topic1/#topic2/DEVICE_ID2/\n| [Feishu](https://appriseit.com/services/feishu/) | feishu://    | (TCP) 443    | feishu://token\n| [Flock](https://appriseit.com/services/flock/) | flock://    | (TCP) 443    | flock://token<br/>flock://botname@token<br/>flock://app_token/u:userid<br/>flock://app_token/g:channel_id<br/>flock://app_token/u:userid/g:channel_id\n| [Google Chat](https://appriseit.com/services/googlechat/) | gchat://    | (TCP) 443    | gchat://workspace/key/token\n| [Gotify](https://appriseit.com/services/gotify/) | gotify:// or gotifys://   | (TCP) 80 or 443    | gotify://hostname/token<br />gotifys://hostname/token?priority=high\n| [Growl](https://appriseit.com/services/growl/)  | growl://   | (UDP) 23053   | growl://hostname<br />growl://hostname:portno<br />growl://password@hostname<br />growl://password@hostname:port</br>**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1\n| [Guilded](https://appriseit.com/services/guilded/)  | guilded://   | (TCP) 443   | guilded://webhook_id/webhook_token<br />guilded://avatar@webhook_id/webhook_token\n| [Home Assistant](https://appriseit.com/services/homeassistant/)       | hassio:// or hassios://   | (TCP) 8123 or 443 | hassio://hostname/accesstoken<br />hassio://user@hostname/accesstoken<br />hassio://user:password@hostname:port/accesstoken<br />hassio://hostname/optional/path/accesstoken\n| [IFTTT](https://appriseit.com/services/ifttt/) | ifttt://    | (TCP) 443    | ifttt://webhooksID/Event<br />ifttt://webhooksID/Event1/Event2/EventN<br/>ifttt://webhooksID/Event1/?+Key=Value<br/>ifttt://webhooksID/Event1/?-Key=value1\n| [IRC](https://appriseit.com/services/irc/) | irc:// or ircs://   | (TCP) 6667 or 6697 | ircs://user:pass@irc.server/@user<br /> ircs://user:pass@irc.server/#channel?join=true&mode=nickserv<br/>ircs://user:pass@znc.server/@user1/@user2/@user3/#channel1\n| [Jellyfin](https://appriseit.com/services/jellyfin/)  | jellyfin:// or jellyfins:// | (TCP) 8096 | jellyfin://user@hostname/<br />jellyfins://user:password@hostname\n| [Join](https://appriseit.com/services/join/) | join://   | (TCP) 443    | join://apikey/device<br />join://apikey/device1/device2/deviceN/<br />join://apikey/group<br />join://apikey/groupA/groupB/groupN<br />join://apikey/DeviceA/groupA/groupN/DeviceN/\n| [KODI](https://appriseit.com/services/kodi/) | kodi:// or kodis://    | (TCP) 8080 or 443   | kodi://hostname<br />kodi://user@hostname<br />kodi://user:password@hostname:port\n| [Kumulos](https://appriseit.com/services/kumulos/) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey\n| [LaMetric Time](https://appriseit.com/services/lametric/) | lametric:// | (TCP) 443 | lametric://apikey@device_ipaddr<br/>lametric://apikey@hostname:port<br/>lametric://client_id@client_secret\n| [Lark](https://appriseit.com/services/lark/) | lark://  | (TCP) 443   | lark://BotToken\n| [Line](https://appriseit.com/services/line/) | line:// | (TCP) 443 | line://Token@User<br/>line://Token/User1/User2/UserN\n| [Mailgun](https://appriseit.com/services/mailgun/) | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey<br />mailgun://user@hostname/apikey/email<br />mailgun://user@hostname/apikey/email1/email2/emailN<br />mailgun://user@hostname/apikey/?name=\"From%20User\"\n| [Mastodon](https://appriseit.com/services/mastodon/) | mastodon:// or mastodons://| (TCP) 80 or 443  | mastodon://access_key@hostname<br />mastodon://access_key@hostname/@user<br />mastodon://access_key@hostname/@user1/@user2/@userN\n| [Matrix](https://appriseit.com/services/matrix/) | matrix:// or matrixs://  | (TCP) 80 or 443 | matrix://hostname<br />matrix://user@hostname<br />matrixs://user:pass@hostname:port/#room_alias<br />matrixs://user:pass@hostname:port/!room_id<br />matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2<br />matrixs://token@hostname:port/?webhook=matrix<br />matrix://user:token@hostname/?webhook=slack&format=markdown\n| [Mattermost](https://appriseit.com/services/mattermost/) | mmost:// or mmosts:// | (TCP) 8065 | mmost://hostname/authkey<br />mmost://hostname:80/authkey<br />mmost://user@hostname:80/authkey<br />mmost://hostname/authkey?channel=channel<br />mmosts://hostname/authkey<br />mmosts://user@hostname/authkey<br />\n| [Microsoft Power Automate / Workflows (MSTeams)](https://appriseit.com/services/workflows/) | workflows://  | (TCP) 443   | workflows://WorkflowID/Signature/\n| [Microsoft Teams](https://appriseit.com/services/msteams/) | msteams://  | (TCP) 443   | msteams://TokenA/TokenB/TokenC/\n| [Misskey](https://appriseit.com/services/misskey/) | misskey:// or misskeys://| (TCP) 80 or 443  | misskey://access_token@hostname\n| [MQTT](https://appriseit.com/services/mqtt/) | mqtt://  or mqtts:// | (TCP) 1883 or 8883   | mqtt://hostname/topic<br />mqtt://user@hostname/topic<br />mqtts://user:pass@hostname:9883/topic\n| [Nextcloud](https://appriseit.com/services/nextcloud/) | ncloud:// or nclouds:// | (TCP) 80 or 443 | ncloud://adminuser:pass@host/User<br/>nclouds://adminuser:pass@host/User1/User2/UserN\n| [NextcloudTalk](https://appriseit.com/services/nextcloudtalk/) | nctalk:// or nctalks:// | (TCP) 80 or 443 | nctalk://user:pass@host/RoomId<br/>nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN\n| [Notica](https://appriseit.com/services/notica/) | notica://  | (TCP) 443   | notica://Token/\n| [NotificationAPI](https://appriseit.com/services/notificationapi/) | napi://  | (TCP) 443   | napi://ClientID/ClientSecret/Target<br />napi://ClientID/ClientSecret/Target1/Target2/TargetN<br />napi://MessageType@ClientID/ClientSecret/Target\n| [Notifiarr](https://appriseit.com/services/notifiarr/) | notifiarr:// | (TCP) 443 | notifiarr://apikey/#channel<br />notifiarr://apikey/#channel1/#channel2/#channeln\n| [Notifico](https://appriseit.com/services/notifico/) | notifico://  | (TCP) 443   | notifico://ProjectID/MessageHook/\n| [ntfy](https://appriseit.com/services/ntfy/) | ntfy://  | (TCP) 80 or 443   | ntfy://topic/<br/>ntfys://topic/\n| [Office 365](https://appriseit.com/services/office365/) | o365://  | (TCP) 443   | o365://TenantID:AccountEmail/ClientID/ClientSecret<br />o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail<br />o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN\n| [OneSignal](https://appriseit.com/services/onesignal/) | onesignal:// | (TCP) 443 | onesignal://AppID@APIKey/PlayerID<br/>onesignal://TemplateID:AppID@APIKey/UserID<br/>onesignal://AppID@APIKey/#IncludeSegment<br/>onesignal://AppID@APIKey/Email\n| [Opsgenie](https://appriseit.com/services/opsgenie/) | opsgenie:// | (TCP) 443 | opsgenie://APIKey<br/>opsgenie://APIKey/UserID<br/>opsgenie://APIKey/#Team<br/>opsgenie://APIKey/\\*Schedule<br/>opsgenie://APIKey/^Escalation\n| [PagerDuty](https://appriseit.com/services/pagerduty/) | pagerduty:// | (TCP) 443 | pagerduty://IntegrationKey@ApiKey<br/>pagerduty://IntegrationKey@ApiKey/Source/Component\n| [PagerTree](https://appriseit.com/services/pagertree/) | pagertree:// | (TCP) 443 | pagertree://integration_id\n| [ParsePlatform](https://appriseit.com/services/parseplatform/) | parsep:// or parseps:// | (TCP) 80 or 443 | parsep://AppID:MasterKey@Hostname<br/>parseps://AppID:MasterKey@Hostname\n| [PopcornNotify](https://appriseit.com/services/popcornnotify/) | popcorn://  | (TCP) 443   | popcorn://ApiKey/ToPhoneNo<br/>popcorn://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>popcorn://ApiKey/ToEmail<br/>popcorn://ApiKey/ToEmail1/ToEmail2/ToEmailN/<br/>popcorn://ApiKey/ToPhoneNo1/ToEmail1/ToPhoneNoN/ToEmailN\n| [Prowl](https://appriseit.com/services/prowl/) | prowl://   | (TCP) 443    | prowl://apikey<br />prowl://apikey/providerkey\n| [PushBullet](https://appriseit.com/services/pushbullet/) | pbul://    | (TCP) 443    | pbul://accesstoken<br />pbul://accesstoken/#channel<br/>pbul://accesstoken/A_DEVICE_ID<br />pbul://accesstoken/email@address.com<br />pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE\n| [Pushjet](https://appriseit.com/services/pushjet/) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret<br />pjet://hostname:port/secret<br />pjets://secret@hostname/secret<br />pjets://hostname:port/secret\n| [Push (Techulus)](https://appriseit.com/services/techulus/) | push://    | (TCP) 443    | push://apikey/\n| [Pushed](https://appriseit.com/services/pushed/) | pushed://    | (TCP) 443    | pushed://appkey/appsecret/<br/>pushed://appkey/appsecret/#ChannelAlias<br/>pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN<br/>pushed://appkey/appsecret/@UserPushedID<br/>pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN\n| [PushMe](https://appriseit.com/services/pushme/) | pushme://  | (TCP) 443   | pushme://Token/\n| [Pushover](https://appriseit.com/services/pushover/)  | pover://   | (TCP) 443   | pover://user@token<br />pover://user@token/DEVICE<br />pover://user@token/DEVICE1/DEVICE2/DEVICEN<br />**Note**: you must specify both your user_id and token\n| [Pushplus](https://appriseit.com/services/pushplus/) | pushplus://  | (TCP) 443   | pushplus://Token\n| [PushSafer](https://appriseit.com/services/pushsafer/)  | psafer:// or psafers://  | (TCP) 80 or 443  | psafer://privatekey<br />psafers://privatekey/DEVICE<br />psafer://privatekey/DEVICE1/DEVICE2/DEVICEN\n| [Pushy](https://appriseit.com/services/pushy/)  | pushy://  | (TCP) 443  | pushy://apikey/DEVICE<br />pushy://apikey/DEVICE1/DEVICE2/DEVICEN<br />pushy://apikey/TOPIC<br />pushy://apikey/TOPIC1/TOPIC2/TOPICN\n| [PushDeer](https://appriseit.com/services/pushdeer/) | pushdeer:// or pushdeers:// | (TCP) 80 or 443 | pushdeer://pushKey<br />pushdeer://hostname/pushKey<br />pushdeer://hostname:port/pushKey\n| [QQ Push](https://appriseit.com/services/qq/) | qq://  | (TCP) 443   | qq://Token\n| [Reddit](https://appriseit.com/services/reddit/) | reddit:// | (TCP) 443   | reddit://user:password@app_id/app_secret/subreddit<br />reddit://user:password@app_id/app_secret/sub1/sub2/subN\n| [Resend](https://appriseit.com/services/resend/) | resend://  | (TCP) 443   | resend://APIToken:FromEmail/<br />resend://APIToken:FromEmail/ToEmail<br />resend://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/\n| [Revolt](https://appriseit.com/services/revolt/) | revolt:// | (TCP) 443   |  revolt://bottoken/ChannelID<br />revolt://bottoken/ChannelID1/ChannelID2/ChannelIDN |\n| [Rocket.Chat](https://appriseit.com/services/rocketchat/) | rocket:// or rockets://  | (TCP) 80 or 443   | rocket://user:password@hostname/RoomID/Channel<br />rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID<br />rocket://user:password@hostname/#Channel<br />rocket://webhook@hostname<br />rockets://webhook@hostname/@User/#Channel\n| [RSyslog](https://appriseit.com/services/rsyslog/) | rsyslog://  | (UDP) 514 | rsyslog://hostname<br />rsyslog://hostname/Facility\n| [Ryver](https://appriseit.com/services/ryver/) | ryver://  | (TCP) 443   | ryver://Organization/Token<br />ryver://botname@Organization/Token\n| [SendGrid](https://appriseit.com/services/sendgrid/) | sendgrid://  | (TCP) 443   | sendgrid://APIToken:FromEmail/<br />sendgrid://APIToken:FromEmail/ToEmail<br />sendgrid://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/\n| [SendPulse](https://appriseit.com/services/sendpulse/) | sendpulse://  | (TCP) 443   | sendpulse://user@host/ClientId/ClientSecret<br />sendpulse://user@host/ClientId/clientSecret/ToEmail<br />sendpulse://user@host/ClientId/ClientSecret/ToEmail1/ToEmail2/ToEmailN/\n| [ServerChan](https://appriseit.com/services/serverchan/) | schan://   | (TCP) 443    | schan://sendkey/\n| [Signal API](https://appriseit.com/services/signal/) | signal://  or signals:// | (TCP) 80 or 443  | signal://hostname:port/FromPhoneNo<br/>signal://hostname:port/FromPhoneNo/ToPhoneNo<br/>signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [SIGNL4](https://appriseit.com/services/signl4/) | signl4://  | (TCP) 80 or 443  | signl4://hostname\n| [SimplePush](https://appriseit.com/services/simplepush/) | spush://   | (TCP) 443    | spush://apikey<br />spush://salt:password@apikey<br />spush://apikey?event=Apprise\n| [Slack](https://appriseit.com/services/slack/) | slack://  | (TCP) 443   | slack://TokenA/TokenB/TokenC/<br />slack://TokenA/TokenB/TokenC/Channel<br />slack://botname@TokenA/TokenB/TokenC/Channel<br />slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN\n| [SMTP2Go](https://appriseit.com/services/smtp2go/) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey<br />smtp2go://user@hostname/apikey/email<br />smtp2go://user@hostname/apikey/email1/email2/emailN<br />smtp2go://user@hostname/apikey/?name=\"From%20User\"\n| [SparkPost](https://appriseit.com/services/sparkpost/) | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey<br />sparkpost://user@hostname/apikey/email<br />sparkpost://user@hostname/apikey/email1/email2/emailN<br />sparkpost://user@hostname/apikey/?name=\"From%20User\"\n| [Spike.sh](https://appriseit.com/services/spike/) | spike://  | (TCP) 443   | spike://Token\n| [Splunk](https://appriseit.com/services/splunk/) | splunk:// or victorops:/ | (TCP) 443 | splunk://route_key@apikey<br />splunk://route_key@apikey/entity_id\n| [Spug Push](https://appriseit.com/services/spugpush/) | spugpush://  | (TCP) 443   | spugpush://Token\n| [Streamlabs](https://appriseit.com/services/streamlabs/) | strmlabs:// | (TCP) 443 | strmlabs://AccessToken/<br/>strmlabs://AccessToken/?name=name&identifier=identifier&amount=0&currency=USD\n| [Synology Chat](https://appriseit.com/services/synology_chat/) | synology:// or synologys:// |  (TCP) 80 or 443 | synology://hostname/token<br />synology://hostname:port/token\n| [Syslog](https://appriseit.com/services/syslog/) | syslog://  | n/a | syslog://<br />syslog://Facility\n| [Telegram](https://appriseit.com/services/telegram/) | tgram://  | (TCP) 443   | tgram://bottoken/ChatID<br />tgram://bottoken/ChatID1/ChatID2/ChatIDN\n| [Twitter](https://appriseit.com/services/twitter/) | twitter://  | (TCP) 443   | twitter://CKey/CSecret/AKey/ASecret<br/>twitter://user@CKey/CSecret/AKey/ASecret<br/>twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2<br/>twitter://CKey/CSecret/AKey/ASecret?mode=tweet\n| [Twist](https://appriseit.com/services/twist/) | twist://  | (TCP) 443   | twist://pasword:login<br/>twist://password:login/#channel<br/>twist://password:login/#team:channel<br/>twist://password:login/#team:channel1/channel2/#team3:channel\n| [Vapid (WebPush)](https://appriseit.com/services/vapid/) | vapid://    | (TCP) 443    | vapid://subscriber/target<br/>vapid://subscriber/target?subfile=path&keyfile=path\n| [Viber](https://appriseit.com/services/viber/) | viber://    | (TCP) 443    | viber://token/target\n| [Webex Teams (Cisco)](https://appriseit.com/services/wxteams/) | wxteams://  | (TCP) 443   | wxteams://Token\n| [WeCom Bot](https://appriseit.com/services/wecombot/) | wecombot://  | (TCP) 443   | wecombot://BotKey\n| [WhatsApp](https://appriseit.com/services/whatsapp/) | whatsapp://  | (TCP) 443   | whatsapp://AccessToken@FromPhoneID/ToPhoneNo<br/>whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo\n| [WxPusher](https://appriseit.com/services/wxpusher/) | wxpusher://  | (TCP) 443   | wxpusher://AppToken@UserID1/UserID2/UserIDN<br/>wxpusher://AppToken@Topic1/Topic2/Topic3<br/>wxpusher://AppToken@UserID1/Topic1/\n| [XBMC](https://appriseit.com/services/xbmc/) | xbmc:// or xbmcs://    | (TCP) 8080 or 443   | xbmc://hostname<br />xbmc://user@hostname<br />xbmc://user:password@hostname:port\n| [XMPP](https://appriseit.com/services/xmpp/) | xmpp:// or xmpps://    | (TCP) 5222 or 5223   | xmpp://user:pass@hostname<br />xmpps://user:pass@hostname/jid<br />xmpps://user:pass@hostname/jid1/jid2@example.ca\n| [Zulip Chat](https://appriseit.com/services/zulip/) | zulip://  | (TCP) 443   | zulip://botname@Organization/Token<br />zulip://botname@Organization/Token/Stream<br />zulip://botname@Organization/Token/Email\n\n## SMS Notifications\n\nSMS Notifications for the most part do not have a both a `title` and `body`.  They consist of a single `body` which is usually no more then 160 characters in length.  When using Apprise, the `title` and `body` are therefore combined into a single message prior to their transmission.\n\n| Notification Service | Service ID | Default Port | Example Syntax |\n| -------------------- | ---------- | ------------ | -------------- |\n| [46elks](https://appriseit.com/services/46elks/) | 46elks://  | (TCP) 443   | 46elks://user:password@FromPhoneNo<br/>46elks://user:password@FromPhoneNo/ToPhoneNo<br/>46elks://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Africas Talking](https://appriseit.com/services/africas_talking/) | atalk://  | (TCP) 443   | atalk://AppUser@ApiKey/ToPhoneNo<br/>atalk://AppUser@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Automated Packet Reporting System (ARPS)](https://appriseit.com/services/aprs/)  | aprs:// | (TCP) 10152 | aprs://user:pass@callsign<br/>aprs://user:pass@callsign1/callsign2/callsignN\n| [AWS SNS](https://appriseit.com/services/sns/)  | sns://   | (TCP) 443   | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo<br/>sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN<br/>sns://AccessKeyID/AccessSecretKey/RegionName/Topic<br/>sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN\n| [BulkSMS](https://appriseit.com/services/bulksms/) | bulksms://  | (TCP) 443   | bulksms://user:password@ToPhoneNo<br/>bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [BulkVS](https://appriseit.com/services/bulkvs/) | bulkvs://  | (TCP) 443   | bulkvs://user:password@FromPhoneNo<br/>bulkvs://user:password@FromPhoneNo/ToPhoneNo<br/>bulkvs://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Burst SMS](https://appriseit.com/services/burstsms/) | burstsms://  | (TCP) 443   | burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo<br/>burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Clickatell](https://appriseit.com/services/clickatell/)                         | clickatell://               | (TCP) 443       | clickatell://ApiKey/ToPhoneNo<br/>clickatell://FromPhoneNo@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN\n| [ClickSend](https://appriseit.com/services/clicksend/) | clicksend://  | (TCP) 443   | clicksend://user:pass@PhoneNo<br/>clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN\n| [DAPNET](https://appriseit.com/services/dapnet/) | dapnet://  | (TCP) 80   | dapnet://user:pass@callsign<br/>dapnet://user:pass@callsign1/callsign2/callsignN\n| [D7 Networks](https://appriseit.com/services/d7networks/) | d7sms://  | (TCP) 443   | d7sms://token@PhoneNo<br/>d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN\n| [DingTalk](https://appriseit.com/services/dingtalk/)  | dingtalk://   | (TCP) 443   | dingtalk://token/<br />dingtalk://token/ToPhoneNo<br />dingtalk://token/ToPhoneNo1/ToPhoneNo2/ToPhoneNo1/\n| [Free-Mobile](https://appriseit.com/services/freemobile/)  | freemobile://   | (TCP) 443   | freemobile://user@password/\n| [httpSMS](https://appriseit.com/services/httpsms/) | httpsms://  | (TCP) 443   | httpsms://ApiKey@FromPhoneNo<br/>httpsms://ApiKey@FromPhoneNo/ToPhoneNo<br/>httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Kavenegar](https://appriseit.com/services/kavenegar/) | kavenegar://  | (TCP) 443   | kavenegar://ApiKey/ToPhoneNo<br/>kavenegar://FromPhoneNo@ApiKey/ToPhoneNo<br/>kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN\n| [MessageBird](https://appriseit.com/services/messagebird/) | msgbird://  | (TCP) 443   | msgbird://ApiKey/FromPhoneNo<br/>msgbird://ApiKey/FromPhoneNo/ToPhoneNo<br/>msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [MSG91](https://appriseit.com/services/msg91/) | msg91://  | (TCP) 443   | msg91://TemplateID@AuthKey/ToPhoneNo<br/>msg91://TemplateID@AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Plivo](https://appriseit.com/services/plivo/) | plivo://  | (TCP) 443   | plivo://AuthID@Token@FromPhoneNo<br/>plivo://AuthID@Token/FromPhoneNo/ToPhoneNo<br/>plivo://AuthID@Token/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Seven](https://appriseit.com/services/seven/)                                   | seven://                    | (TCP) 443   | seven://ApiKey/FromPhoneNo<br/>seven://ApiKey/FromPhoneNo/ToPhoneNo<br/>seven://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Société Française du Radiotéléphone (SFR)](https://appriseit.com/services/sfr/) | sfr://   | (TCP) 443    | sfr://user:password>@spaceId/ToPhoneNo<br/>sfr://user:password>@spaceId/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Signal API](https://appriseit.com/services/signal/) | signal://  or signals:// | (TCP) 80 or 443  | signal://hostname:port/FromPhoneNo<br/>signal://hostname:port/FromPhoneNo/ToPhoneNo<br/>signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Sinch](https://appriseit.com/services/sinch/) | sinch://  | (TCP) 443   | sinch://ServicePlanId:ApiToken@FromPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo<br/>sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [SMPP](https://appriseit.com/services/smpp/)    | smpp:// or smpps://  | (TCP) 443  | smpp://user:password@hostname:port/FromPhoneNo/ToPhoneNo<br/>smpps://user:password@hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN\n| [SMSEagle](https://appriseit.com/services/smseagle/) | smseagle:// or smseagles:// | (TCP) 80 or 443  | smseagles://hostname:port/ToPhoneNo<br/>smseagles://hostname:port/@ToContact<br/>smseagles://hostname:port/#ToGroup<br/>smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/\n| [SMS Manager](https://appriseit.com/services/sms_manager/) | smsmgr://  | (TCP) 443   | smsmgr://ApiKey@ToPhoneNo<br/>smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Threema Gateway](https://appriseit.com/services/threema/) | threema://  | (TCP) 443   | threema://GatewayID@secret/ToPhoneNo<br/>threema://GatewayID@secret/ToEmail<br/>threema://GatewayID@secret/ToThreemaID/<br/>threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/...\n| [Twilio](https://appriseit.com/services/twilio/) | twilio://  | (TCP) 443   | twilio://AccountSid:AuthToken@FromPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo<br/>twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?method=call<br/>twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN?method=call\n| [Voipms](https://appriseit.com/services/voipms/) | voipms://  | (TCP) 443   | voipms://password:email/FromPhoneNo<br/>voipms://password:email/FromPhoneNo/ToPhoneNo<br/>voipms://password:email/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n| [Vonage](https://appriseit.com/services/vonage/) (formerly Nexmo) | vonage://  | (TCP) 443   | vonage://ApiKey:ApiSecret@FromPhoneNo<br/>vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo<br/>vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/\n\n## Desktop Notifications\n\n| Notification Service | Service ID | Default Port | Example Syntax |\n| -------------------- | ---------- | ------------ | -------------- |\n| [Linux DBus Notifications](https://appriseit.com/services/dbus/)  | dbus://<br />qt://<br />glib://<br />kde://  | n/a  | dbus://<br />qt://<br />glib://<br />kde://\n| [Linux Gnome Notifications](https://appriseit.com/services/gnome/) | gnome://    |        n/a          | gnome://\n| [MacOS X Notifications](https://appriseit.com/services/macosx/) | macosx://    |        n/a          | macosx://\n| [Windows Notifications](https://appriseit.com/services/windows/) | windows://    |        n/a          | windows://\n\n## Email Notifications\n\n| Service ID | Default Port | Example Syntax |\n| ---------- | ------------ | -------------- |\n| [mailto://](https://appriseit.com/services/email/)  |  (TCP) 25    | mailto://userid:pass@domain.com<br />mailto://domain.com?user=userid&pass=password<br/>mailto://domain.com:2525?user=userid&pass=password<br />mailto://user@gmail.com&pass=password<br />mailto://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com<br />mailto://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply\n| [mailtos://](https://appriseit.com/services/email/) |  (TCP) 587   | mailtos://userid:pass@domain.com<br />mailtos://domain.com?user=userid&pass=password<br/>mailtos://domain.com:465?user=userid&pass=password<br />mailtos://user@hotmail.com&pass=password<br />mailtos://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com<br />mailtos://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply\n\nApprise have some email services built right into it (such as yahoo, fastmail, hotmail, gmail, etc) that greatly simplify the mailto:// service.  See more details [here](https://appriseit.com/services/email/).\n\n## Custom Notifications\n\n| Post Method          | Service ID | Default Port | Example Syntax |\n| -------------------- | ---------- | ------------ | -------------- |\n| [Form](https://appriseit.com/services/form/)       | form:// or forms://   | (TCP) 80 or 443 | form://hostname<br />form://user@hostname<br />form://user:password@hostname:port<br />form://hostname/a/path/to/post/to\n| [JSON](https://appriseit.com/services/json/)       | json:// or jsons://   | (TCP) 80 or 443 | json://hostname<br />json://user@hostname<br />json://user:password@hostname:port<br />json://hostname/a/path/to/post/to\n| [XML](https://appriseit.com/services/xml/)         | xml:// or xmls://   | (TCP) 80 or 443 | xml://hostname<br />xml://user@hostname<br />xml://user:password@hostname:port<br />xml://hostname/a/path/to/post/to\n\n# Installation\n\nThe easiest way is to install Apprise from PyPI:\n```bash\npip install apprise\n```\n\nApprise is also packaged as an RPM and available through [EPEL](https://docs.fedoraproject.org/en-US/epel/) supporting CentOS, Redhat, Rocky, Oracle Linux, etc.\n```bash\n# Follow instructions on https://docs.fedoraproject.org/en-US/epel\n# to get your system connected up to EPEL and then:\n# Redhat/CentOS 7.x users\nyum install apprise\n\n# Redhat/Rocky Linux 8.x+ and/or Fedora Users\ndnf install apprise\n```\n\nYou can also check out the [Graphical version of Apprise](https://github.com/caronc/apprise-api) to centralize your configuration and notifications through a manageable webpage.\n\n# Command Line Usage\n\nA small command line interface (CLI) tool is also provided with this package called *apprise*. If you know the server urls you wish to notify, you can simply provide them all on the command line and send your notifications that way:\n```bash\n# Send a notification to as many servers as you want\n# as you can easily chain one after another (the -vv provides some\n# additional verbosity to help let you know what is going on):\napprise -vv -t 'my title' -b 'my notification body' \\\n   'mailto://myemail:mypass@gmail.com' \\\n   'pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b'\n\n# If you don't specify a --body (-b) then stdin is used allowing\n# you to use the tool as part of your every day administration:\ncat /proc/cpuinfo | apprise -vv -t 'cpu info' \\\n   'mailto://myemail:mypass@gmail.com'\n\n# The title field is totally optional\nuptime | apprise -vv \\\n   'discord:///4174216298/JHMHI8qBe7bk2ZwO5U711o3dV_js'\n```\n\n## CLI Configuration Files\n\nNo one wants to put their credentials out for everyone to see on the command line.  No problem *apprise* also supports configuration files.  It can handle both a specific YAML format or a very simple TEXT format. You can also pull these configuration files via an HTTP query too! Read more about the expected structure of the configuration files [here](https://appriseit.com/config/).\n\n```bash\n# By default if no url or configuration is specified apprise will attempt to load\n# configuration files (if present) from:\n#  ~/.apprise\n#  ~/.apprise.yaml\n#  ~/.config/apprise.conf\n#  ~/.config/apprise.yaml\n#  /etc/apprise.conf\n#  /etc/apprise.yaml\n\n# Also a subdirectory handling allows you to leverage plugins\n#  ~/.apprise/apprise\n#  ~/.apprise/apprise.yaml\n#  ~/.config/apprise/apprise.conf\n#  ~/.config/apprise/apprise.yaml\n#  /etc/apprise/apprise.yaml\n#  /etc/apprise/apprise.conf\n\n# Windows users can store their default configuration files here:\n#  %APPDATA%/Apprise/apprise.conf\n#  %APPDATA%/Apprise/apprise.yaml\n#  %LOCALAPPDATA%/Apprise/apprise.conf\n#  %LOCALAPPDATA%/Apprise/apprise.yaml\n#  %ALLUSERSPROFILE%\\Apprise\\apprise.conf\n#  %ALLUSERSPROFILE%\\Apprise\\apprise.yaml\n#  %PROGRAMFILES%\\Apprise\\apprise.conf\n#  %PROGRAMFILES%\\Apprise\\apprise.yaml\n#  %COMMONPROGRAMFILES%\\Apprise\\apprise.conf\n#  %COMMONPROGRAMFILES%\\Apprise\\apprise.yaml\n\n# The configuration files specified above can also be identified with a `.yml`\n# extension or even just entirely removing the `.conf` extension altogether.\n\n# If you loaded one of those files, your command line gets really easy:\napprise -vv -t 'my title' -b 'my notification body'\n\n# If you want to deviate from the default paths or specify more than one,\n# just specify them using the --config switch:\napprise -vv -t 'my title' -b 'my notification body' \\\n   --config=/path/to/my/config.yml\n\n# Got lots of configuration locations? No problem, you can specify them all:\n# Apprise can even fetch the configuration from over a network!\napprise -vv -t 'my title' -b 'my notification body' \\\n   --config=/path/to/my/config.yml \\\n   --config=https://localhost/my/apprise/config\n```\n\n## CLI Tagging Support\n\nApprise allows you to tag your services in your configuration to organize them (e.g., `family`, `devops`, `critical`). You can then filter which services to notify using the `--tag` (`-g`) switch.\n\nIt is important to understand how Apprise handles multiple tags:\n\n* **OR Logic (Union)**: To notify services that have *either* Tag A **OR** Tag B, specify the `-g` switch multiple times.\n* **AND Logic (Intersection)**: To notify services that have *both* Tag A **AND** Tag B, separate the tags with a comma.\n\n```bash\n# OR Logic: Notify any service tagged 'devops' OR 'admin'\napprise -vv -t \"Union Test\" \\\n   --config=~/apprise.yml \\\n   -g devops -g admin\n\n# AND Logic: Notify only services tagged with BOTH 'devops' AND 'critical'\napprise -vv -t \"Intersection Test\" \\\n   --config=~/apprise.yml \\\n   -g devops,critical\n\n## CLI File Attachments\n\nApprise also supports file attachments too! Specify as many attachments to a notification as you want.\n```bash\n# Send a funny image you found on the internet to a colleague:\napprise -vv --title 'Agile Joke' \\\n        --body 'Did you see this one yet?' \\\n        --attach https://i.redd.it/my2t4d2fx0u31.jpg \\\n        'mailto://myemail:mypass@gmail.com'\n\n# Easily send an update from a critical server to your dev team\napprise -vv --title 'system crash' \\\n        --body 'I do not think Jim fixed the bug; see attached...' \\\n        --attach /var/log/myprogram.log \\\n        --attach /var/debug/core.2345 \\\n        --tag devteam\n```\n\n## CLI Loading Custom Notifications/Hooks\n\nTo create your own custom `schema://` hook so that you can trigger your own custom code,\nsimply include the `@notify` decorator to wrap your function.\n```python\nfrom apprise.decorators import notify\n#\n# The below assumes you want to catch foobar:// calls:\n#\n@notify(on=\"foobar\", name=\"My Custom Foobar Plugin\")\ndef my_custom_notification_wrapper(body, title, notify_type, *args, **kwargs):\n    \"\"\"My custom notification function that triggers on all foobar:// calls\n    \"\"\"\n    # Write all of your code here... as an example...\n    print(\"{}: {} - {}\".format(notify_type.upper(), title, body))\n\n    # Returning True/False is a way to relay your status back to Apprise.\n    # Returning nothing (None by default) is always interpreted as a Success\n```\n\nOnce you've defined your custom hook, you just need to tell Apprise where it is at runtime.\n```bash\n# By default if no plugin path is specified apprise will attempt to load\n# all plugin files (if present) from the following directory paths:\n#  ~/.apprise/plugins\n#  ~/.config/apprise/plugins\n#  /var/lib/apprise/plugins\n\n# Windows users can store their default plugin files in these directories:\n#  %APPDATA%/Apprise/plugins\n#  %LOCALAPPDATA%/Apprise/plugins\n#  %ALLUSERSPROFILE%\\Apprise\\plugins\n#  %PROGRAMFILES%\\Apprise\\plugins\n#  %COMMONPROGRAMFILES%\\Apprise\\plugins\n\n# If you placed your plugin file within one of the directories already defined\n# above, then your call simply needs to look like:\napprise -vv --title 'custom override' \\\n        --body 'the body of my message' \\\n        foobar:\\\\\n\n# However you can override the path like so\napprise -vv --title 'custom override' \\\n        --body 'the body of my message' \\\n        --plugin-path /path/to/my/plugin.py \\\n        foobar:\\\\\n```\n\nYou can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/).\n\n## CLI Environment Variables\n\nThose using the Command Line Interface (CLI) can also leverage environment variables to pre-set the default settings:\n\n| Variable                | Description       |\n|------------------------ | ----------------- |\n| `APPRISE_URLS`          |  Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the `--config` (`-c`) is specified, then this will overrides any reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries.\n|  `APPRISE_CONFIG_PATH`  | Explicitly specify the config search path to use (overriding the default). The path(s) defined here must point to the absolute filename to open/reference. Use a semi-colon (`;`), line-feed (`\\n`), and/or carriage return (`\\r`) to delimit multiple entries.\n|  `APPRISE_PLUGIN_PATH`  | Explicitly specify the custom plugin search path to use (overriding the default). Use a semi-colon (`;`), line-feed (`\\n`), and/or carriage return (`\\r`) to delimit multiple entries.\n|  `APPRISE_STORAGE_PATH` | Explicitly specify the persistent storage path to use (overriding the default).\n\n# Developer API Usage\n\nTo send a notification from within your python application, just do the following:\n```python\nimport apprise\n\n# Create an Apprise instance\napobj = apprise.Apprise()\n\n# Add all of the notification services by their server url.\n# A sample email notification:\napobj.add('mailto://myuserid:mypass@gmail.com')\n\n# A sample pushbullet notification\napobj.add('pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b')\n\n# Then notify these services any time you desire. The below would\n# notify all of the services loaded into our Apprise object.\napobj.notify(\n    body='what a great notification service!',\n    title='my notification title',\n)\n```\n\n## API Configuration Files\n\nDevelopers need access to configuration files too. The good news is their use just involves declaring another object (called *AppriseConfig*) that the *Apprise* object can ingest.  You can also freely mix and match config and notification entries as often as you wish! You can read more about the expected structure of the configuration files [here](https://appriseit.com/getting-started/configuration/).\n```python\nimport apprise\n\n# Create an Apprise instance\napobj = apprise.Apprise()\n\n# Create an Config instance\nconfig = apprise.AppriseConfig()\n\n# Add a configuration source:\nconfig.add('/path/to/my/config.yml')\n\n# Add another...\nconfig.add('https://myserver:8080/path/to/config')\n\n# Make sure to add our config into our apprise object\napobj.add(config)\n\n# You can mix and match; add an entry directly if you want too\n# In this entry we associate the 'admin' tag with our notification\napobj.add('mailto://myuser:mypass@hotmail.com', tag='admin')\n\n# Then notify these services any time you desire. The below would\n# notify all of the services that have not been bound to any specific\n# tag.\napobj.notify(\n    body='what a great notification service!',\n    title='my notification title',\n)\n\n# Tagging allows you to specifically target only specific notification\n# services you've loaded:\napobj.notify(\n    body='send a notification to our admin group',\n    title='Attention Admins',\n    # notify any services tagged with the 'admin' tag\n    tag='admin',\n)\n\n# If you want to notify absolutely everything (regardless of whether\n# it's been tagged or not), just use the reserved tag of 'all':\napobj.notify(\n    body='send a notification to our admin group',\n    title='Attention Admins',\n    # notify absolutely everything loaded, regardless on whether\n    # it has a tag associated with it or not:\n    tag='all',\n)\n```\n\n## API File Attachments\n\nAttachments are very easy to send using the Apprise API:\n```python\nimport apprise\n\n# Create an Apprise instance\napobj = apprise.Apprise()\n\n# Add at least one service you want to notify\napobj.add('mailto://myuser:mypass@hotmail.com')\n\n# Then send your attachment.\napobj.notify(\n    title='A great photo of our family',\n    body='The flash caused Jane to close her eyes! hah! :)',\n    attach='/local/path/to/my/DSC_003.jpg',\n)\n\n# Send a web based attachment too! In the below example, we connect to a home\n# security camera and send a live image to an email. By default remote web\n# content is cached, but for a security camera we might want to call notify\n# again later in our code, so we want our last image retrieved to expire(in\n# this case after 3 seconds).\napobj.notify(\n    title='Latest security image',\n    attach='http://admin:password@hikvision-cam01/ISAPI/Streaming/channels/101/picture?cache=3'\n)\n```\n\nTo send more than one attachment, just use a list, set, or tuple instead:\n```python\nimport apprise\n\n# Create an Apprise instance\napobj = apprise.Apprise()\n\n# Add at least one service you want to notify\napobj.add('mailto://myuser:mypass@hotmail.com')\n\n# Now add all of the entries we're interested in:\nattach = (\n    # ?name= allows us to rename the actual jpeg as found on the site\n    # to be another name when sent to our receipient(s)\n    'https://i.redd.it/my2t4d2fx0u31.jpg?name=FlyingToMars.jpg',\n\n    # Now add another:\n    '/path/to/funny/joke.gif',\n)\n\n# Send your multiple attachments with a single notify call:\napobj.notify(\n    title='Some good jokes.',\n    body='Hey guys, check out these!',\n    attach=attach,\n)\n```\n\n## API Loading Custom Notifications/Hooks\n\nBy default, no custom plugins are loaded at all for those building from within the Apprise API.\nIt's at the developers discretion to load custom modules. But should you choose to do so, it's as easy\nas including the path reference in the `AppriseAsset()` object prior to the initialization of your `Apprise()`\ninstance.\n\nFor example:\n```python\nfrom apprise import Apprise\nfrom apprise import AppriseAsset\n\n# Prepare your Asset object so that you can enable the custom plugins to\n# be loaded for your instance of Apprise...\nasset = AppriseAsset(plugin_paths=\"/path/to/scan\")\n\n# OR You can also generate scan more then one file too:\nasset = AppriseAsset(\n    plugin_paths=[\n        # Iterate over all python libraries found in the root of the\n        # specified path. This is NOT a recursive (directory) scan; only\n        # the first level is parsed. HOWEVER, if a directory containing\n        # an __init__.py is found, it will be included in the load.\n        \"/dir/containing/many/python/libraries\",\n\n        # An absolute path to a plugin.py to exclusively load\n        \"/path/to/plugin.py\",\n\n        # if you point to a directory that has an __init__.py file found in\n        # it, then only that file is loaded (it's similar to point to a\n        # absolute .py file. Hence, there is no (level 1) scanning at all\n        # within the directory specified.\n        \"/path/to/dir/library\"\n    ]\n)\n\n# Now that we've got our asset, we just work with our Apprise object as we\n# normally do\naobj = Apprise(asset=asset)\n\n# If our new custom `foobar://` library was loaded (presuming we prepared\n# one like in the examples above).  then you would be able to safely add it\n# into Apprise at this point\naobj.add('foobar://')\n\n# Send our notification out through our foobar://\naobj.notify(\"test\")\n```\n\nYou can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/).\n\n# Persistent Storage\n\nPersistent storage allows Apprise to cache re-occurring actions optionaly to disk. This can greatly reduce the overhead used to send a notification.\n\nThere are 3 Persistent Storage operational states Apprise can operate using:\n1. `auto`:  Flush gathered cache information to the filesystem on demand.  This option is incredibly light weight.  This is the default behavior for all CLI usage.\n   * Developers who choose to use this operational mode can also force cached information manually if they choose.\n   * The CLI will use this operational mode by default.\n1. `flush`: Flushes any cache information to the filesystem during every transaction.\n1. `memory`: Effectively disable Persistent Storage.  Any caching of data required by each plugin used is done in memory.  Apprise effectively operates as it always did before peristent storage was available. This setting ensures no content is every written to disk.\n   * By default this is the mode Apprise will operate under for those developing with it unless they configure it to otherwise operate as `auto` or `flush`.  This is done through the `AppriseAsset()` object and is explained further on in this documentation.\n\n## CLI Persistent Storage Commands\n\nYou can provide the keyword `storage` on your CLI call to see the persistent storage options available to you.\n```bash\n# List all of the occupied space used by Apprise's Persistent Storage:\napprise storage list\n\n# list is the default option, so the following does the same thing:\napprise storage\n\n# You can prune all of your storage older then 30 days\n# and not accessed for this period like so:\napprise storage prune\n\n# You can do a hard reset (and wipe all persistent storage) with:\napprise storage clean\n\n```\n\nYou can also filter your results by adding tags and/or URL Identifiers.  When you get a listing (`apprise storage list`), you may see:\n```\n   # example output of 'apprise storage list':\n   1. f7077a65                                             0.00B    unused\n      - matrixs://abcdef:****@synapse.example12.com/%23general?image=no&mode=off&version=3&msgtype...\n      tags: team\n\n   2. 0e873a46                                            81.10B    active\n      - tgram://W...U//?image=False&detect=yes&silent=no&preview=no&content=before&mdv=v1&format=m...\n      tags: personal\n\n   3. abcd123                                             12.00B    stale\n\n```\nThe (persistent storage) cache states are:\n - `unused`: This plugin has not commited anything to disk for reuse/cache purposes\n - `active`: This plugin has written content to disk.  Or at the very least, it has prepared a persistent storage location it can write into.\n - `stale`: The system detected a location where a URL may have possibly written to in the past, but there is nothing linking to it using the URLs provided.  It is likely wasting space or is no longer of any use.\n\nYou can use this information to filter your results by specifying _URL ID_ (UID) values after your command.  For example:\n```bash\n# The below commands continue with the example already identified above\n# the following would match abcd123 (even though just ab was provided)\n# The output would only list the 'stale' entry above\napprise storage list ab\n\n# knowing our filter is safe, we could remove it\n# the below command would not obstruct our other to URLs and would only\n# remove our stale one:\napprise storage clean ab\n\n# Entries can be filtered by tag as well:\napprise storage list --tag=team\n\n# You can match on multiple URL ID's as well:\n# The followin would actually match the URL ID's of 1. and .2 above\napprise storage list f 0\n```\nWhen using the CLI, Persistent storage is set to the operational mode of `auto` by default, you can change this by providing `--storage-mode=` (`-SM`) during your calls.  If you want to ensure it's always set to a value of your choice.\n\nFor more information on persistent storage, [visit here](https://appriseit.com/cli/persistent-storage/).\n\n## API Persistent Storage Commands\nFor developers, persistent storage is set in the operational mode of `memory` by default.\n\nIt's at the developers discretion to enable it (by switching it to either `auto` or `flush`). Should you choose to do so: it's as easy as including the information in the `AppriseAsset()` object prior to the initialization of your `Apprise()` instance.\n\nFor example:\n```python\nfrom apprise import Apprise\nfrom apprise import AppriseAsset\nfrom apprise import PersistentStoreMode\n\n# Prepare a location the persistent storage can write it's cached content to.\n# By setting this path, this immediately assumes you wish to operate the\n# persistent storage in the operational 'auto' mode\nasset = AppriseAsset(storage_path=\"/path/to/save/data\")\n\n# If you want to be more explicit and set more options, then you may do the\n# following\nasset = AppriseAsset(\n    # Set our storage path directory (minimum requirement to enable it)\n    storage_path=\"/path/to/save/data\",\n\n    # Set the mode... the options are:\n    # 1. PersistentStoreMode.MEMORY\n    #       - disable persistent storage from writing to disk\n    # 2. PersistentStoreMode.AUTO\n    #       - write to disk on demand\n    # 3. PersistentStoreMode.FLUSH\n    #       - write to disk always and often\n    storage_mode=PersistentStoreMode.FLUSH\n\n    # The URL IDs are by default 8 characters in length. You can increase and\n    # decrease it's value here.  The value must be > 2. The default value is 8\n    # if not otherwise specified\n    storage_idlen=8,\n)\n\n# Now that we've got our asset, we just work with our Apprise object as we\n# normally do\naobj = Apprise(asset=asset)\n```\n\nFor more information on persistent storage, [visit here](https://appriseit.com/library/persistent-storage/).\n\n# Want To Learn More?\n\nIf you're interested in reading more about this and other methods on how to customize your own notifications, please check out the following links:\n* 📣 [Using the CLI](https://appriseit.com/cli/)\n* 🛠️ [Development API](https://appriseit.com/library/)\n* ⚙️ [Configuration File Help](https://appriseit.com/getting-started/configuration/)\n* ⚡ [Create Your Own Custom Notifications](https://appriseit.com/library/extending/decorator/)\n* 🌎 [Apprise API/Web Interface](https://github.com/caronc/apprise-api/)\n* 📖 [Apprise Documentation Source](https://github.com/caronc/apprise-docs/)\n* 🔧 [Troubleshooting](https://appriseit.com/qa/)\n* 🎉 [Showcase](https://appriseit.com/contributing/showcase/)\n\nWant to help make Apprise better?\n* 💡 [Contribute to the Apprise Code Base](https://appriseit.com/contributing/)\n* ❤️ [Sponsorship and Donations](https://appriseit.com/contributing/sponsors/)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 0.9.x   | :white_check_mark: |\n| < 0.9.x | :x:                |\n\n## Reporting a Vulnerability\n\nIf you find a vunerability, please notify me at lead2gold@gmail.com. If the vunerability\nis severe then please just open a ticket at https://github.com/caronc/apprise/issues\n"
  },
  {
    "path": "all-plugin-requirements.txt",
    "content": "#\n# Note: This file is being kept for backwards compatibility with\n#       legacy systems that point here.  All future changes should\n#       occur in pyproject.toml.  Contents of this file can be found\n#       in [project.optional-dependencies].all-plugins\n\n# Provides fcm:// and spush://\ncryptography\n\n# Provides growl:// support\ngntp\n\n# Provides mqtt:// support\n# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814\npaho-mqtt != 2.0.*\n\n# Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support\nPGPy\n\n# Provides smpp:// support\nsmpplib\n\n# For xmpp:// support\nslixmpp >= 1.10.0\n"
  },
  {
    "path": "apprise/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n__title__ = \"Apprise\"\n__description__: str = \\\n    \"Push Notifications that work with just about every platform!\"\n__version__ = \"1.9.8\"\n__author__ = \"Chris Caron\"\n__email__ = \"lead2gold@gmail.com\"\n__license__ = \"BSD 2-Clause\"\n__copyright__ = \"Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\"\n__status__ = \"Production\"\n\nfrom . import decorators, exception\nfrom .apprise import Apprise\nfrom .apprise_attachment import AppriseAttachment\nfrom .apprise_config import AppriseConfig\nfrom .asset import AppriseAsset\nfrom .attachment.base import AttachBase\nfrom .common import (\n    CONFIG_FORMATS,\n    CONTENT_INCLUDE_MODES,\n    CONTENT_LOCATIONS,\n    NOTIFY_FORMATS,\n    NOTIFY_IMAGE_SIZES,\n    NOTIFY_TYPES,\n    OVERFLOW_MODES,\n    PERSISTENT_STORE_MODES,\n    PERSISTENT_STORE_STATES,\n    ConfigFormat,\n    ContentIncludeMode,\n    ContentLocation,\n    NotifyFormat,\n    NotifyImageSize,\n    NotifyType,\n    OverflowMode,\n    PersistentStoreMode,\n)\nfrom .config.base import ConfigBase\nfrom .locale import AppriseLocale\n\n# Inherit our logging with our additional entries added to it\nfrom .logger import LOGGER_NAME, LogCapture, logger, logging\nfrom .manager_attachment import AttachmentManager\nfrom .manager_config import ConfigurationManager\nfrom .manager_plugins import NotificationManager\nfrom .persistent_store import PersistentStore\nfrom .plugins.base import NotifyBase\nfrom .url import PrivacyMode, URLBase\n\n# Set default logging handler to avoid \"No handler found\" warnings.\nlogging.getLogger(__name__).addHandler(logging.NullHandler())\n\n__all__ = [\n    \"CONFIG_FORMATS\",\n    \"CONTENT_INCLUDE_MODES\",\n    \"CONTENT_LOCATIONS\",\n    \"LOGGER_NAME\",\n    \"NOTIFY_FORMATS\",\n    \"NOTIFY_IMAGE_SIZES\",\n    \"NOTIFY_TYPES\",\n    \"OVERFLOW_MODES\",\n    \"PERSISTENT_STORE_MODES\",\n    \"PERSISTENT_STORE_STATES\",\n    # Core\n    \"Apprise\",\n    \"AppriseAsset\",\n    \"AppriseAttachment\",\n    \"AppriseConfig\",\n    \"AppriseLocale\",\n    \"AttachBase\",\n    \"AttachmentManager\",\n    \"ConfigBase\",\n    \"ConfigFormat\",\n    \"ConfigurationManager\",\n    \"ContentIncludeMode\",\n    \"ContentLocation\",\n    \"LogCapture\",\n    # Managers\n    \"NotificationManager\",\n    \"NotifyBase\",\n    \"NotifyFormat\",\n    \"NotifyImageSize\",\n    # Reference\n    \"NotifyType\",\n    \"OverflowMode\",\n    \"PersistentStore\",\n    \"PersistentStoreMode\",\n    \"PrivacyMode\",\n    \"URLBase\",\n    # Decorator\n    \"decorators\",\n    # Exceptions\n    \"exception\",\n    # Logging\n    \"logger\",\n    \"logging\",\n]\n"
  },
  {
    "path": "apprise/apprise.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom collections.abc import Iterator\nimport concurrent.futures as cf\nfrom itertools import chain\nimport json\nimport os\nfrom typing import Any, Optional, Union\n\nfrom . import __version__, common, plugins\nfrom .apprise_attachment import AppriseAttachment\nfrom .apprise_config import AppriseConfig\nfrom .asset import AppriseAsset\nfrom .common import ContentLocation\nfrom .config.base import ConfigBase\nfrom .conversion import convert_between\nfrom .emojis import apply_emojis\nfrom .locale import AppriseLocale\nfrom .logger import logger\nfrom .manager_plugins import NotificationManager\nfrom .plugins.base import NotifyBase\nfrom .utils.cwe312 import cwe312_url\nfrom .utils.json import AppriseJSONEncoder\nfrom .utils.logic import is_exclusive_match\nfrom .utils.parse import parse_list, parse_urls\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\nclass Apprise:\n    \"\"\"Our Notification Manager.\"\"\"\n\n    def __init__(\n        self,\n        servers: Optional[\n            Union[\n                str,\n                dict,\n                NotifyBase,\n                AppriseConfig,\n                ConfigBase,\n                list[Union[str, dict, NotifyBase, AppriseConfig, ConfigBase]],\n            ]\n        ] = None,\n        asset: Optional[AppriseAsset] = None,\n        location: Optional[ContentLocation] = None,\n        debug: bool = False,\n    ) -> None:\n        \"\"\"Loads a set of server urls while applying the Asset() module to each\n        if specified.\n\n        If no asset is provided, then the default asset is used.\n\n        Optionally specify a global ContentLocation for a more strict means of\n        handling Attachments.\n        \"\"\"\n\n        # Initialize a server list of URLs\n        self.servers = []\n\n        # Assigns an central asset object that will be later passed into each\n        # notification plugin.  Assets contain information such as the local\n        # directory images can be found in. It can also identify remote\n        # URL paths that contain the images you want to present to the end\n        # user. If no asset is specified, then the default one is used.\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        if servers:\n            self.add(servers)\n\n        # Initialize our locale object\n        self.locale = AppriseLocale()\n\n        # Set our debug flag\n        self.debug = debug\n\n        # Store our hosting location for optional strict rule handling\n        # of Attachments.  Setting this to None removes any attachment\n        # restrictions.\n        self.location = location\n\n    @staticmethod\n    def instantiate(\n        url: Union[str, dict],\n        asset: Optional[AppriseAsset] = None,\n        tag: Optional[Union[str, list[str]]] = None,\n        suppress_exceptions: bool = True,\n    ) -> Optional[NotifyBase]:\n        \"\"\"Returns the instance of a instantiated plugin based on the provided\n        Server URL.  If the url fails to be parsed, then None is returned.\n\n        The specified url can be either a string (the URL itself) or a\n        dictionary containing all of the components needed to istantiate\n        the notification service.  If identifying a dictionary, at the bare\n        minimum, one must specify the schema.\n\n        An example of a url dictionary object might look like:\n          {\n            schema: 'mailto',\n            host: 'google.com',\n            user: 'myuser',\n            password: 'mypassword',\n          }\n\n        Alternatively the string is much easier to specify:\n          mailto://user:mypassword@google.com\n\n        The dictionary works well for people who are calling details() to\n        extract the components they need to build the URL manually.\n        \"\"\"\n\n        # Initialize our result set\n        results = None\n\n        # Prepare our Asset Object\n        asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n\n        if isinstance(url, str):\n            # Acquire our url tokens\n            results = plugins.url_to_dict(\n                url, secure_logging=asset.secure_logging\n            )\n\n            if results is None:\n                # Failed to parse the server URL; detailed logging handled\n                # inside url_to_dict - nothing to report here.\n                return None\n\n        elif isinstance(url, dict):\n            # We already have our result set\n            results = url\n\n            if results.get(\"schema\") not in N_MGR:\n                # schema is a mandatory dictionary item as it is the only way\n                # we can index into our loaded plugins\n                logger.error('Dictionary does not include a \"schema\" entry.')\n                logger.trace(\n                    \"Invalid dictionary unpacked as:{}{}\".format(\n                        os.linesep,\n                        os.linesep.join(\n                            [f'{k}=\"{v}\"' for k, v in results.items()]\n                        ),\n                    )\n                )\n                return None\n\n            logger.trace(\n                \"Dictionary unpacked as:{}{}\".format(\n                    os.linesep,\n                    os.linesep.join(\n                        [f'{k}=\"{v}\"' for k, v in results.items()]\n                    ),\n                )\n            )\n\n        # Otherwise we handle the invalid input specified\n        else:\n            logger.error(\n                \"An invalid URL type (%s) was specified for instantiation\",\n                type(url),\n            )\n            return None\n\n        if not N_MGR[results[\"schema\"]].enabled:\n            #\n            # First Plugin Enable Check (Pre Initialization)\n            #\n\n            # Plugin has been disabled at a global level\n            logger.error(\n                \"%s:// is disabled on this system.\", results[\"schema\"]\n            )\n            return None\n\n        # Build a list of tags to associate with the newly added notifications\n        results[\"tag\"] = set(parse_list(tag))\n\n        # Set our Asset Object\n        results[\"asset\"] = asset\n\n        if suppress_exceptions:\n            try:\n                # Attempt to create an instance of our plugin using the parsed\n                # URL information\n                plugin = N_MGR[results[\"schema\"]](**results)\n\n                # Create log entry of loaded URL\n                logger.debug(\n                    \"Loaded {} URL: {}\".format(\n                        N_MGR[results[\"schema\"]].service_name,\n                        plugin.url(privacy=asset.secure_logging),\n                    )\n                )\n\n            except Exception:\n                # CWE-312 (Secure Logging) Handling\n                loggable_url = (\n                    url if not asset.secure_logging else cwe312_url(url)\n                )\n\n                # the arguments are invalid or can not be used.\n                logger.error(\n                    \"Could not load {} URL: {}\".format(\n                        N_MGR[results[\"schema\"]].service_name, loggable_url\n                    )\n                )\n                return None\n\n        else:\n            # Attempt to create an instance of our plugin using the parsed\n            # URL information but don't wrap it in a try catch\n            plugin = N_MGR[results[\"schema\"]](**results)\n\n        if not plugin.enabled:\n            #\n            # Second Plugin Enable Check (Post Initialization)\n            #\n\n            # Service/Plugin is disabled (on a more local level).  This is a\n            # case where the plugin was initially enabled but then after the\n            # __init__() was called under the hood something pre-determined\n            # that it could no longer be used.\n\n            # The only downside to doing it this way is services are\n            # initialized prior to returning the details() if 3rd party tools\n            # are polling what is available. These services that become\n            # disabled thereafter are shown initially that they can be used.\n            logger.error(\n                \"%s:// has become disabled on this system.\", results[\"schema\"]\n            )\n            return None\n\n        return plugin\n\n    def add(\n        self,\n        servers: Union[\n            str,\n            dict,\n            NotifyBase,\n            AppriseConfig,\n            ConfigBase,\n            list[Union[str, dict, NotifyBase, AppriseConfig, ConfigBase]],\n        ],\n        asset: Optional[AppriseAsset] = None,\n        tag: Optional[Union[str, list[str]]] = None,\n    ) -> bool:\n        \"\"\"Adds one or more server URLs into our list.\n\n        You can override the global asset if you wish by including it with the\n        server(s) that you add.\n\n        The tag allows you to associate 1 or more tag values to the server(s)\n        being added. tagging a service allows you to exclusively access them\n        when calling the notify() function.\n        \"\"\"\n\n        # Initialize our return status\n        return_status = True\n\n        if asset is None:\n            # prepare default asset\n            asset = self.asset\n\n        if isinstance(servers, str):\n            # build our server list\n            servers = parse_urls(servers)\n            if len(servers) == 0:\n                return False\n\n        elif isinstance(servers, dict):\n            # no problem, we support kwargs, convert it to a list\n            servers = [servers]\n\n        elif isinstance(servers, (ConfigBase, NotifyBase, AppriseConfig)):\n            # Go ahead and just add our plugin into our list\n            self.servers.append(servers)\n            return True\n\n        elif not isinstance(servers, (tuple, set, list)):\n            logger.error(\n                f\"An invalid notification (type={type(servers)}) was\"\n                \" specified.\"\n            )\n            return False\n\n        for server in servers:\n\n            if isinstance(server, (ConfigBase, NotifyBase, AppriseConfig)):\n                # Go ahead and just add our plugin into our list\n                self.servers.append(server)\n                continue\n\n            elif not isinstance(server, (str, dict)):\n                logger.error(\n                    f\"An invalid notification (type={type(server)}) was\"\n                    \" specified.\"\n                )\n                return_status = False\n                continue\n\n            # Instantiate ourselves an object, this function throws or\n            # returns None if it fails\n            instance = Apprise.instantiate(server, asset=asset, tag=tag)\n            if not isinstance(instance, NotifyBase):\n                # No logging is required as instantiate() handles failure\n                # and/or success reasons for us\n                return_status = False\n                continue\n\n            # Add our initialized plugin to our server listings\n            self.servers.append(instance)\n\n        # Return our status\n        return return_status\n\n    def clear(self) -> None:\n        \"\"\"Empties our server list.\"\"\"\n        self.servers[:] = []\n\n    def find(\n        self,\n        tag: Any = common.MATCH_ALL_TAG,\n        match_always: bool = True,\n    ) -> Iterator[NotifyBase]:\n        \"\"\"Returns a list of all servers matching against the tag specified.\"\"\"\n\n        # Build our tag setup\n        #   - top level entries are treated as an 'or'\n        #   - second level (or more) entries are treated as 'and'\n        #\n        #   examples:\n        #     tag=\"tagA, tagB\"                = tagA or tagB\n        #     tag=['tagA', 'tagB']            = tagA or tagB\n        #     tag=[('tagA', 'tagC'), 'tagB']  = (tagA and tagC) or tagB\n        #     tag=[('tagB', 'tagC')]          = tagB and tagC\n\n        # A match_always flag allows us to pick up on our 'any' keyword\n        # and notify these services under all circumstances\n        match_always = common.MATCH_ALWAYS_TAG if match_always else None\n\n        # Iterate over our loaded plugins\n        for entry in self.servers:\n\n            if isinstance(entry, (ConfigBase, AppriseConfig)):\n                # load our servers\n                servers = entry.servers()\n\n            else:\n                servers = [\n                    entry,\n                ]\n\n            for server in servers:\n                # Apply our tag matching based on our defined logic\n                if is_exclusive_match(\n                    logic=tag,\n                    data=server.tags,\n                    match_all=common.MATCH_ALL_TAG,\n                    match_always=match_always,\n                ):\n                    yield server\n        return\n\n    def notify(\n        self,\n        body: Union[str, bytes],\n        title: Union[str, bytes] = \"\",\n        notify_type: Union[str, common.NotifyType] = common.NotifyType.INFO,\n        body_format: Optional[str] = None,\n        tag: Any = common.MATCH_ALL_TAG,\n        match_always: bool = True,\n        attach: Any = None,\n        interpret_escapes: Optional[bool] = None,\n    ) -> Optional[bool]:\n        \"\"\"Send a notification to all the plugins previously loaded.\n\n        If the body_format specified is NotifyFormat.MARKDOWN, it will be\n        converted to HTML if the Notification type expects this.\n\n        if the tag is specified (either a string or a set/list/tuple of\n        strings), then only the notifications flagged with that tagged value\n        are notified.  By default, all added services are notified\n        (tag=MATCH_ALL_TAG)\n\n        This function returns True if all notifications were successfully sent,\n        False if even just one of them fails, and None if no notifications were\n        sent at all as a result of tag filtering and/or simply having empty\n        configuration files that were read.\n\n        Attach can contain a list of attachment URLs.  attach can also be\n        represented by an AttachBase() (or list of) object(s). This identifies\n        the products you wish to notify\n\n        Set interpret_escapes to True if you want to pre-escape a string such\n        as turning a \\n into an actual new line, etc.\n        \"\"\"\n\n        try:\n            # Process arguments and build synchronous and asynchronous calls\n            # (this step can throw internal errors).\n            sequential_calls, parallel_calls = self._create_notify_calls(\n                body,\n                title,\n                notify_type=notify_type,\n                body_format=body_format,\n                tag=tag,\n                match_always=match_always,\n                attach=attach,\n                interpret_escapes=interpret_escapes,\n            )\n\n        except TypeError:\n            # No notifications sent, and there was an internal error.\n            return False\n\n        if not sequential_calls and not parallel_calls:\n            # Nothing to send\n            return None\n\n        sequential_result = Apprise._notify_sequential(*sequential_calls)\n        parallel_result = Apprise._notify_parallel_threadpool(*parallel_calls)\n        return sequential_result and parallel_result\n\n    async def async_notify(\n        self,\n        *args: Any,\n        **kwargs: Any\n    ) -> Optional[bool]:\n        \"\"\"Send a notification to all the plugins previously loaded, for\n        asynchronous callers.\n\n        The arguments are identical to those of Apprise.notify().\n        \"\"\"\n        try:\n            # Process arguments and build synchronous and asynchronous calls\n            # (this step can throw internal errors).\n            sequential_calls, parallel_calls = self._create_notify_calls(\n                *args, **kwargs\n            )\n\n        except TypeError:\n            # No notifications sent, and there was an internal error.\n            return False\n\n        if not sequential_calls and not parallel_calls:\n            # Nothing to send\n            return None\n\n        sequential_result = Apprise._notify_sequential(*sequential_calls)\n        parallel_result = await Apprise._notify_parallel_asyncio(\n            *parallel_calls\n        )\n        return sequential_result and parallel_result\n\n    def _create_notify_calls(self, *args, **kwargs):\n        \"\"\"Creates notifications for all the plugins loaded.\n\n        Returns a list of (server, notify() kwargs) tuples for plugins with\n        parallelism disabled and another list for plugins with parallelism\n        enabled.\n        \"\"\"\n\n        all_calls = list(self._create_notify_gen(*args, **kwargs))\n\n        # Split into sequential and parallel notify() calls.\n        sequential, parallel = [], []\n        for server, notify_kwargs in all_calls:\n            if server.asset.async_mode:\n                parallel.append((server, notify_kwargs))\n            else:\n                sequential.append((server, notify_kwargs))\n\n        return sequential, parallel\n\n    def _create_notify_gen(\n        self,\n        body,\n        title=\"\",\n        notify_type=common.NotifyType.INFO,\n        body_format=None,\n        tag=common.MATCH_ALL_TAG,\n        match_always=True,\n        attach=None,\n        interpret_escapes=None,\n    ):\n        \"\"\"Internal generator function for _create_notify_calls().\"\"\"\n\n        if len(self) == 0:\n            # Nothing to notify\n            msg = \"There are no service(s) to notify\"\n            logger.error(msg)\n            raise TypeError(msg)\n\n        if not (title or body or attach):\n            msg = \"No message content specified to deliver\"\n            logger.error(msg)\n            raise TypeError(msg)\n\n        try:\n            notify_type = (\n                notify_type if isinstance(notify_type, common.NotifyType)\n                else common.NotifyType(notify_type.lower())\n            )\n\n        except (AttributeError, ValueError, TypeError):\n            err = (\n                f\"An invalid notification type ({notify_type}) was \"\n                \"specified.\")\n            raise TypeError(err) from None\n\n        try:\n            if title and isinstance(title, bytes):\n                title = title.decode(self.asset.encoding)\n\n            if body and isinstance(body, bytes):\n                body = body.decode(self.asset.encoding)\n\n        except UnicodeDecodeError:\n            msg = (\n                \"The content passed into Apprise was not of encoding \"\n                f\"type: {self.asset.encoding}\"\n            )\n            logger.error(msg)\n            raise TypeError(msg) from None\n\n        # Tracks conversions\n        conversion_body_map = {}\n        conversion_title_map = {}\n\n        # Prepare attachments if required\n        if attach is not None and not isinstance(attach, AppriseAttachment):\n            attach = AppriseAttachment(\n                attach, asset=self.asset, location=self.location\n            )\n\n        # Allow Asset default value\n        body_format = (\n            self.asset.body_format if body_format is None else body_format\n        )\n\n        # Allow Asset default value\n        interpret_escapes = (\n            self.asset.interpret_escapes\n            if interpret_escapes is None\n            else interpret_escapes\n        )\n\n        # Iterate over our loaded plugins\n        for server in self.find(tag, match_always=match_always):\n            # If our code reaches here, we either did not define a tag (it\n            # was set to None), or we did define a tag and the logic above\n            # determined we need to notify the service it's associated with\n\n            # First we need to generate a key we will use to determine if we\n            # need to build our data out.  Entries without are merged with\n            # the body at this stage.\n            key = (\n                server.notify_format\n                if server.title_maxlen > 0\n                else f\"_{server.notify_format}\"\n            )\n\n            if server.interpret_emojis:\n                # alter our key slightly to handle emojis since their value is\n                # pulled out of the notification\n                key += \"-emojis\"\n\n            if key not in conversion_title_map:\n\n                # Prepare our title\n                conversion_title_map[key] = title if title else \"\"\n\n                # Conversion of title only occurs for services where the title\n                # is blended with the body (title_maxlen <= 0)\n                if conversion_title_map[key] and server.title_maxlen <= 0:\n                    conversion_title_map[key] = convert_between(\n                        body_format,\n                        server.notify_format,\n                        content=conversion_title_map[key],\n                    )\n\n                # Our body is always converted no matter what\n                conversion_body_map[key] = convert_between(\n                    body_format, server.notify_format, content=body\n                )\n\n                if interpret_escapes:\n                    #\n                    # Escape our content\n                    #\n\n                    try:\n                        # Added overhead required due to Python 3 Encoding Bug\n                        # identified here: https://bugs.python.org/issue21331\n                        conversion_body_map[key] = (\n                            conversion_body_map[key]\n                            .encode(\"ascii\", \"backslashreplace\")\n                            .decode(\"unicode-escape\")\n                        )\n\n                        conversion_title_map[key] = (\n                            conversion_title_map[key]\n                            .encode(\"ascii\", \"backslashreplace\")\n                            .decode(\"unicode-escape\")\n                        )\n\n                    except AttributeError:\n                        # Must be of string type\n                        msg = \"Failed to escape message body\"\n                        logger.error(msg)\n                        raise TypeError(msg) from None\n\n                if server.interpret_emojis:\n                    #\n                    # Convert our :emoji: definitions\n                    #\n\n                    conversion_body_map[key] = apply_emojis(\n                        conversion_body_map[key]\n                    )\n                    conversion_title_map[key] = apply_emojis(\n                        conversion_title_map[key]\n                    )\n\n            kwargs = {\n                \"body\": conversion_body_map[key],\n                \"title\": conversion_title_map[key],\n                \"notify_type\": notify_type,\n                \"attach\": attach,\n                \"body_format\": body_format,\n            }\n            yield (server, kwargs)\n\n    @staticmethod\n    def _notify_sequential(*servers_kwargs):\n        \"\"\"Process a list of notify() calls sequentially and synchronously.\"\"\"\n\n        success = True\n\n        for server, kwargs in servers_kwargs:\n            try:\n                # Send notification\n                result = server.notify(**kwargs)\n                success = success and result\n\n            except TypeError:\n                # These are our internally thrown notifications.\n                success = False\n\n            except Exception:\n                # A catch all so we don't have to abort early\n                # just because one of our plugins has a bug in it.\n                logger.exception(\"Unhandled Notification Exception\")\n                success = False\n\n        return success\n\n    @staticmethod\n    def _notify_parallel_threadpool(*servers_kwargs):\n        \"\"\"Process a list of notify() calls in parallel and synchronously.\"\"\"\n\n        n_calls = len(servers_kwargs)\n\n        # 0-length case\n        if n_calls == 0:\n            return True\n\n        # There's no need to use a thread pool for just a single notification\n        if n_calls == 1:\n            return Apprise._notify_sequential(servers_kwargs[0])\n\n        # Create log entry\n        logger.info(\n            \"Notifying %d service(s) with threads.\", len(servers_kwargs)\n        )\n\n        with cf.ThreadPoolExecutor() as executor:\n            success = True\n            futures = [\n                executor.submit(server.notify, **kwargs)\n                for (server, kwargs) in servers_kwargs\n            ]\n\n            for future in cf.as_completed(futures):\n                try:\n                    result = future.result()\n                    success = success and result\n\n                except TypeError:\n                    # These are our internally thrown notifications.\n                    success = False\n\n                except Exception:\n                    # A catch all so we don't have to abort early\n                    # just because one of our plugins has a bug in it.\n                    logger.exception(\"Unhandled Notification Exception\")\n                    success = False\n\n            return success\n\n    @staticmethod\n    async def _notify_parallel_asyncio(*servers_kwargs):\n        \"\"\"Process a list of async_notify() calls in parallel and\n        asynchronously.\"\"\"\n\n        n_calls = len(servers_kwargs)\n\n        # 0-length case\n        if n_calls == 0:\n            return True\n\n        # (Unlike with the thread pool, we don't optimize for the single-\n        # notification case because asyncio can do useful work while waiting\n        # for that thread to complete)\n\n        # Create log entry\n        logger.info(\n            \"Notifying %d service(s) asynchronously.\", len(servers_kwargs)\n        )\n\n        async def do_call(server, kwargs):\n            return await server.async_notify(**kwargs)\n\n        cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs)\n        results = await asyncio.gather(*cors, return_exceptions=True)\n\n        if any(\n            isinstance(status, Exception) and not isinstance(status, TypeError)\n            for status in results\n        ):\n            # A catch all so we don't have to abort early just because\n            # one of our plugins has a bug in it.\n            logger.exception(\"Unhandled Notification Exception\")\n            return False\n\n        if any(isinstance(status, TypeError) for status in results):\n            # These are our internally thrown notifications.\n            return False\n\n        return all(results)\n\n    def json(\n        self,\n        lang: Optional[str] = None,\n        show_requirements: bool = False,\n        show_disabled: bool = False,\n        indent: Optional[int] = None,\n        path: Optional[str] = None,\n    ) -> Union[str, bool]:\n        \"\"\"Returns a json response associated with the Apprise object.\"\"\"\n        details = self.details(\n            lang=lang,\n            show_requirements=show_requirements,\n            show_disabled=show_disabled,\n        )\n\n        if not path:\n            return json.dumps(\n                details,\n                separators=(\",\", \":\"),\n                indent=indent,\n                cls=AppriseJSONEncoder,\n            )\n\n        with open(path, \"w\") as fp:\n            try:\n                json.dump(\n                    details,\n                    fp,\n                    separators=(\",\", \":\"),\n                    indent=indent,\n                    cls=AppriseJSONEncoder,\n                    ensure_ascii=False,\n                )\n\n            except (OSError, EOFError) as e:\n                logger.error(\n                    \"Apprise details dumpfile inaccessible: %s\", path\n                )\n                logger.debug(\"Apprise details dump Exception: %s\", e)\n\n                # Early Exit\n                return False\n\n            finally:\n                # Reduce memory\n                del details\n\n        return True\n\n    def details(\n        self,\n        lang: Optional[str] = None,\n        show_requirements: bool = False,\n        show_disabled: bool = False,\n    ) -> dict[str, Any]:\n        \"\"\"Returns the details associated with the Apprise object.\"\"\"\n\n        # general object returned\n        response = {\n            # Defines the current version of Apprise\n            \"version\": __version__,\n            # Lists all of the currently supported Notifications\n            \"schemas\": [],\n            # Includes the configured asset details\n            \"asset\": self.asset.details(),\n        }\n\n        for plugin in N_MGR.plugins():\n            # Iterate over our hashed plugins and dynamically build details on\n            # their status:\n\n            content = {\n                \"service_name\": getattr(plugin, \"service_name\", None),\n                \"service_url\": getattr(plugin, \"service_url\", None),\n                \"setup_url\": getattr(plugin, \"setup_url\", None),\n                # Placeholder - populated below\n                \"details\": None,\n                # Let upstream service know of the plugins that support\n                # attachments\n                \"attachment_support\": getattr(\n                    plugin, \"attachment_support\", False\n                ),\n                # Differentiat between what is a custom loaded plugin and\n                # which is native.\n                \"category\": getattr(plugin, \"category\", None),\n            }\n\n            # Standard protocol(s) should be None or a tuple\n            enabled = getattr(plugin, \"enabled\", True)\n            if not show_disabled and not enabled:\n                # Do not show inactive plugins\n                continue\n\n            elif show_disabled:\n                # Add current state to response\n                content[\"enabled\"] = enabled\n\n            # Standard protocol(s) should be None or a tuple\n            protocols = getattr(plugin, \"protocol\", None)\n            if isinstance(protocols, str):\n                protocols = (protocols,)\n\n            # Secure protocol(s) should be None or a tuple\n            secure_protocols = getattr(plugin, \"secure_protocol\", None)\n            if isinstance(secure_protocols, str):\n                secure_protocols = (secure_protocols,)\n\n            # Add our protocol details to our content\n            content.update({\n                \"protocols\": protocols,\n                \"secure_protocols\": secure_protocols,\n            })\n\n            if not lang:\n                # Simply return our results\n                content[\"details\"] = plugins.details(plugin)\n                if show_requirements:\n                    content[\"requirements\"] = plugins.requirements(plugin)\n\n            else:\n                # Emulate the specified language when returning our results\n                with self.locale.lang_at(lang):\n                    content[\"details\"] = plugins.details(plugin)\n                    if show_requirements:\n                        content[\"requirements\"] = plugins.requirements(plugin)\n\n            # Build our response object\n            response[\"schemas\"].append(content)\n\n        return response\n\n    def urls(self, privacy: bool = False) -> list[str]:\n        \"\"\"Returns all of the loaded URLs defined in this apprise object.\"\"\"\n        urls = []\n        for s in self.servers:\n            if isinstance(s, (ConfigBase, AppriseConfig)):\n                for s_ in s.servers():\n                    urls.append(s_.url(privacy=privacy))\n            else:\n                urls.append(s.url(privacy=privacy))\n        return urls\n\n    def pop(self, index: int) -> NotifyBase:\n        \"\"\"Removes an indexed Notification Service from the stack and returns\n        it.\n\n        The thing is we can never pop AppriseConfig() entries, only what was\n        loaded within them. So pop needs to carefully iterate over our list and\n        only track actual entries.\n        \"\"\"\n\n        # Tracking variables\n        prev_offset = -1\n        offset = prev_offset\n\n        for idx, s in enumerate(self.servers):\n            if isinstance(s, (ConfigBase, AppriseConfig)):\n                servers = s.servers()\n                if len(servers) > 0:\n                    # Acquire a new maximum offset to work with\n                    offset = prev_offset + len(servers)\n\n                    if offset >= index:\n                        # we can pop an element from our config stack\n                        fn = (\n                            s.pop\n                            if isinstance(s, ConfigBase)\n                            else s.server_pop\n                        )\n\n                        return fn(\n                            index\n                            if prev_offset == -1\n                            else (index - prev_offset - 1)\n                        )\n\n            else:\n                offset = prev_offset + 1\n                if offset == index:\n                    return self.servers.pop(idx)\n\n            # Update our old offset\n            prev_offset = offset\n\n        # If we reach here, then we indexed out of range\n        raise IndexError(\"list index out of range\")\n\n    def __getitem__(self, index: int) -> NotifyBase:\n        \"\"\"Returns the indexed server entry of a loaded notification server.\"\"\"\n        # Tracking variables\n        prev_offset = -1\n        offset = prev_offset\n\n        for idx, s in enumerate(self.servers):\n            if isinstance(s, (ConfigBase, AppriseConfig)):\n                # Get our list of servers associate with our config object\n                servers = s.servers()\n                if len(servers) > 0:\n                    # Acquire a new maximum offset to work with\n                    offset = prev_offset + len(servers)\n\n                    if offset >= index:\n                        return servers[(\n                            index\n                            if prev_offset == -1\n                            else (index - prev_offset - 1)\n                        )]\n\n            else:\n                offset = prev_offset + 1\n                if offset == index:\n                    return self.servers[idx]\n\n            # Update our old offset\n            prev_offset = offset\n\n        # If we reach here, then we indexed out of range\n        raise IndexError(\"list index out of range\")\n\n    def __getstate__(self) -> dict[str, object]:\n        \"\"\"Pickle Support dumps()\"\"\"\n        attributes = {\n            \"asset\": self.asset,\n            # Prepare our URL list as we need to extract the associated tags\n            # and asset details associated with it\n            \"urls\": [\n                {\n                    \"url\": server.url(privacy=False),\n                    \"tag\": server.tags if server.tags else None,\n                    \"asset\": server.asset,\n                }\n                for server in self.servers\n            ],\n            \"locale\": self.locale,\n            \"debug\": self.debug,\n            \"location\": self.location.value if self.location else None,\n        }\n\n        return attributes\n\n    def __setstate__(self, state: dict[str, object]) -> None:\n        \"\"\"Pickle Support loads()\"\"\"\n        self.servers = []\n        self.asset = state[\"asset\"]\n        self.locale = state[\"locale\"]\n\n        location = state.get(\"location\")\n        self.location = (\n            location if isinstance(location, ContentLocation)\n            else ContentLocation(location)\n            if location is not None\n            else None\n        )\n\n        for entry in state[\"urls\"]:\n            self.add(entry[\"url\"], asset=entry[\"asset\"], tag=entry[\"tag\"])\n\n    def __bool__(self) -> bool:\n        \"\"\"Allows the Apprise object to be wrapped in an 'if statement'.\n\n        True is returned if at least one service has been loaded.\n        \"\"\"\n        return len(self) > 0\n\n    def __iter__(self) -> Iterator[NotifyBase]:\n        \"\"\"Returns an iterator to each of our servers loaded.\n\n        This includes those found inside configuration.\n        \"\"\"\n        return chain(*[\n            (\n                [s]\n                if not isinstance(s, (ConfigBase, AppriseConfig))\n                else iter(s.servers())\n            )\n            for s in self.servers\n        ])\n\n    def __len__(self) -> int:\n        \"\"\"Returns the number of servers loaded; this includes those found\n        within loaded configuration.\n\n        This funtion nnever actually counts the Config entry themselves (if\n        they exist), only what they contain.\n        \"\"\"\n        return sum((\n                1\n                if not isinstance(s, (ConfigBase, AppriseConfig))\n                else len(s.servers())\n            )\n            for s in self.servers)\n"
  },
  {
    "path": "apprise/apprise_attachment.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom collections.abc import Iterator\nfrom typing import Any, Optional, Union\n\nfrom .asset import AppriseAsset\nfrom .attachment.base import AttachBase\nfrom .common import ContentLocation\nfrom .logger import logger\nfrom .manager_attachment import AttachmentManager\nfrom .url import URLBase\nfrom .utils.parse import GET_SCHEMA_RE\n\n# Grant access to our Notification Manager Singleton\nA_MGR = AttachmentManager()\n\n\nclass AppriseAttachment:\n    \"\"\"Our Apprise Attachment File Manager.\"\"\"\n\n    def __init__(\n        self,\n        paths: Optional[Union[str, list[\n            Union[str, AttachBase, \"AppriseAttachment\"]]]] = None,\n        asset: Optional[AppriseAsset] = None,\n        cache: Union[bool, int] = True,\n        location: Optional[Union[str, ContentLocation]] = None,\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"Loads all of the paths/urls specified (if any).\n\n        The path can either be a single string identifying one explicit\n        location, otherwise you can pass in a series of locations to scan\n        via a list.\n\n        By default we cache our responses so that subsiquent calls does not\n        cause the content to be retrieved again.  For local file references\n        this makes no difference at all.  But for remote content, this does\n        mean more then one call can be made to retrieve the (same) data.  This\n        method can be somewhat inefficient if disabled.  Only disable caching\n        if you understand the consequences.\n\n        You can alternatively set the cache value to an int identifying the\n        number of seconds the previously retrieved can exist for before it\n        should be considered expired.\n\n        It's also worth nothing that the cache value is only set to elements\n        that are not already of subclass AttachBase()\n\n        Optionally set your current ContentLocation in the location argument.\n        This is used to further handle attachments. The rules are as follows:\n          - INACCESSIBLE: You simply have disabled use of the object; no\n                          attachments will be retrieved/handled.\n          - HOSTED:       You are hosting an attachment service for others.\n                          In these circumstances all attachments that are LOCAL\n                          based (such as file://) will not be allowed.\n          - LOCAL:        The least restrictive mode as local files can be\n                          referenced in addition to hosted.\n\n        In all but HOSTED and LOCAL modes, INACCESSIBLE attachment types will\n        continue to be inaccessible.  However if you set this field (location)\n        to None (it's default value) the attachment location category will not\n        be tested in any way (all attachment types will be allowed).\n\n        The location field is also a global option that can be set when\n        initializing the Apprise object.\n        \"\"\"\n\n        # Initialize our attachment listings\n        self.attachments = []\n\n        # Set our cache flag\n        self.cache = cache\n\n        # Prepare our Asset Object\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        if location:\n            try:\n                self.location = (\n                    location if isinstance(location, ContentLocation)\n                    else ContentLocation(location.lower())\n                )\n\n            except (AttributeError, ValueError):\n                err = (\n                    f\"An invalid Attachment location ({location}) was \"\n                    \"specified.\",\n                )\n                logger.warning(err)\n                raise TypeError(err) from None\n        else:\n            # do not set location if no initialization was made for it\n            self.location = None\n\n        # Now parse any paths specified\n        if paths is not None and not self.add(paths):\n            raise TypeError(\"One or more attachments could not be added.\")\n\n    def add(\n        self,\n        attachments: Union[\n            str,\n            AttachBase,\n            \"AppriseAttachment\",\n            list[Union[str, AttachBase, \"AppriseAttachment\"]],\n        ],\n        asset: Optional[AppriseAsset] = None,\n        cache: Optional[Union[bool, int]] = None,\n    ) -> bool:\n        \"\"\"Adds one or more attachments into our list.\n\n        By default we cache our responses so that subsiquent calls does not\n        cause the content to be retrieved again.  For local file references\n        this makes no difference at all.  But for remote content, this does\n        mean more then one call can be made to retrieve the (same) data.  This\n        method can be somewhat inefficient if disabled.  Only disable caching\n        if you understand the consequences.\n\n        You can alternatively set the cache value to an int identifying the\n        number of seconds the previously retrieved can exist for before it\n        should be considered expired.\n\n        It's also worth nothing that the cache value is only set to elements\n        that are not already of subclass AttachBase()\n        \"\"\"\n        # Initialize our return status\n        return_status = True\n\n        # Initialize our default cache value\n        cache = cache if cache is not None else self.cache\n\n        if asset is None:\n            # prepare default asset\n            asset = self.asset\n\n        if isinstance(attachments, (AttachBase, str)):\n            # store our instance\n            attachments = (attachments,)\n\n        elif not isinstance(attachments, (tuple, set, list)):\n            logger.error(\n                f\"An invalid attachment url (type={type(attachments)}) was \"\n                \"specified.\"\n            )\n            return False\n\n        # Iterate over our attachments\n        for attachment in attachments:\n            if self.location == ContentLocation.INACCESSIBLE:\n                logger.warning(\n                    f\"Attachments are disabled; ignoring {attachment}\"\n                )\n                return_status = False\n                continue\n\n            if isinstance(attachment, str):\n                logger.debug(f\"Loading attachment: {attachment}\")\n                # Instantiate ourselves an object, this function throws or\n                # returns None if it fails\n                instance = AppriseAttachment.instantiate(\n                    attachment, asset=asset, cache=cache\n                )\n                if not isinstance(instance, AttachBase):\n                    return_status = False\n                    continue\n\n            elif isinstance(attachment, AppriseAttachment):\n                # We were provided a list of Apprise Attachments\n                # append our content together\n                instance = attachment.attachments\n\n            elif not isinstance(attachment, AttachBase):\n                logger.warning(\n                    f\"An invalid attachment (type={type(attachment)}) was\"\n                    \" specified.\"\n                )\n                return_status = False\n                continue\n\n            else:\n                # our entry is of type AttachBase, so just go ahead and point\n                # our instance to it for some post processing below\n                instance = attachment\n\n            # Apply some simple logic if our location flag is set\n            if self.location and (\n                (\n                    self.location == ContentLocation.HOSTED\n                    and instance.location != ContentLocation.HOSTED\n                )\n                or instance.location == ContentLocation.INACCESSIBLE\n            ):\n                logger.warning(\n                    \"Attachment was disallowed due to accessibility\"\n                    f\" restrictions ({self.location}->{instance.location}):\"\n                    f\" {instance.url(privacy=True)}\"\n                )\n                return_status = False\n                continue\n\n            # Add our initialized plugin to our server listings\n            if isinstance(instance, list):\n                self.attachments.extend(instance)\n\n            else:\n                self.attachments.append(instance)\n\n        # Return our status\n        return return_status\n\n    @staticmethod\n    def instantiate(\n        url: str,\n        asset: Optional[AppriseAsset] = None,\n        cache: Optional[Union[bool, int]] = None,\n        suppress_exceptions: bool = True,\n    ) -> Optional[AttachBase]:\n        \"\"\"Returns the instance of a instantiated attachment plugin based on\n        the provided Attachment URL.  If the url fails to be parsed, then None\n        is returned.\n\n        A specified cache value will over-ride anything set\n        \"\"\"\n        # Attempt to acquire the schema at the very least to allow our\n        # attachment based urls.\n        schema = GET_SCHEMA_RE.match(url)\n        if schema is None:\n            # Plan B is to assume we're dealing with a file\n            schema = \"file\"\n            url = f\"{schema}://{URLBase.quote(url)}\"\n\n        else:\n            # Ensure our schema is always in lower case\n            schema = schema.group(\"schema\").lower()\n\n            # Some basic validation\n            if schema not in A_MGR:\n                logger.warning(f\"Unsupported schema {schema}.\")\n                return None\n\n        # Parse our url details of the server object as dictionary containing\n        # all of the information parsed from our URL\n        results = A_MGR[schema].parse_url(url)\n\n        if not results:\n            # Failed to parse the server URL\n            logger.warning(f\"Unparseable URL {url}.\")\n            return None\n\n        # Prepare our Asset Object\n        results[\"asset\"] = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        if cache is not None:\n            # Force an over-ride of the cache value to what we have specified\n            results[\"cache\"] = cache\n\n        if suppress_exceptions:\n            try:\n                # Attempt to create an instance of our plugin using the parsed\n                # URL information\n                attach_plugin = A_MGR[results[\"schema\"]](**results)\n\n            except Exception:\n                # the arguments are invalid or can not be used.\n                logger.warning(f\"Could not load URL: {url}\")\n                return None\n\n        else:\n            # Attempt to create an instance of our plugin using the parsed\n            # URL information but don't wrap it in a try catch\n            attach_plugin = A_MGR[results[\"schema\"]](**results)\n\n        return attach_plugin\n\n    def sync(\n        self,\n        abort_on_error: bool = True,\n        abort_if_empty: bool = True,\n    ) -> bool:\n        \"\"\"Itereates over all of the attachments and retrieves them.\"\"\"\n        return (\n            False\n            if abort_if_empty and not self.attachments\n            else (\n                next((False for a in self.attachments if not a), True)\n                if abort_on_error\n                else next((True for a in self.attachments), True)\n            )\n        )\n\n    def clear(self) -> None:\n        \"\"\"Empties our attachment list.\"\"\"\n        self.attachments[:] = []\n\n    def size(self) -> int:\n        \"\"\"Returns the total size of accumulated attachments.\"\"\"\n        return sum(len(a) for a in self.attachments if len(a) > 0)\n\n    def pop(self, index: int = -1) -> AttachBase:\n        \"\"\"Removes an indexed Apprise Attachment from the stack and returns it.\n\n        by default the last element is poped from the list\n        \"\"\"\n        # Remove our entry\n        return self.attachments.pop(index)\n\n    def __getitem__(self, index: int) -> AttachBase:\n        \"\"\"Returns the indexed entry of a loaded apprise attachments.\"\"\"\n        return self.attachments[index]\n\n    def __bool__(self) -> bool:\n        \"\"\"Allows the Apprise object to be wrapped in an 'if statement'.\n\n        True is returned if at least one service has been loaded.\n        \"\"\"\n        return bool(self.attachments)\n\n    def __iter__(self) -> Iterator[AttachBase]:\n        \"\"\"Returns an iterator to our attachment list.\"\"\"\n        return iter(self.attachments)\n\n    def __len__(self) -> int:\n        \"\"\"Returns the number of attachment entries loaded.\"\"\"\n        return len(self.attachments)\n"
  },
  {
    "path": "apprise/apprise_config.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any\n\nfrom . import common\nfrom .asset import AppriseAsset\nfrom .config.base import ConfigBase\nfrom .logger import logger\nfrom .manager_config import ConfigurationManager\nfrom .url import URLBase\nfrom .utils.logic import is_exclusive_match\nfrom .utils.parse import GET_SCHEMA_RE, parse_list\n\nif TYPE_CHECKING:\n    from .plugins.base import NotifyBase\n\n# Grant access to our Configuration Manager Singleton\nC_MGR = ConfigurationManager()\n\n\nclass AppriseConfig:\n    \"\"\"Our Apprise Configuration File Manager.\n\n    - Supports a list of URLs defined one after another (text format)\n    - Supports a destinct YAML configuration format\n    \"\"\"\n\n    def __init__(\n        self,\n        paths: str | list[str] | None = None,\n        asset: AppriseAsset | None = None,\n        cache: bool | int = True,\n        recursion: int = 0,\n        insecure_includes: bool = False,\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"Loads all of the paths specified (if any).\n\n        The path can either be a single string identifying one explicit\n        location, otherwise you can pass in a series of locations to scan\n        via a list.\n\n        If no path is specified then a default list is used.\n\n        By default we cache our responses so that subsiquent calls does not\n        cause the content to be retrieved again. Setting this to False does\n        mean more then one call can be made to retrieve the (same) data.  This\n        method can be somewhat inefficient if disabled and you're set up to\n        make remote calls.  Only disable caching if you understand the\n        consequences.\n\n        You can alternatively set the cache value to an int identifying the\n        number of seconds the previously retrieved can exist for before it\n        should be considered expired.\n\n        It's also worth nothing that the cache value is only set to elements\n        that are not already of subclass ConfigBase()\n\n        recursion defines how deep we recursively handle entries that use the\n        `import` keyword. This keyword requires us to fetch more configuration\n        from another source and add it to our existing compilation. If the\n        file we remotely retrieve also has an `import` reference, we will only\n        advance through it if recursion is set to 2 deep.  If set to zero\n        it is off.  There is no limit to how high you set this value. It would\n        be recommended to keep it low if you do intend to use it.\n\n        insecure includes by default are disabled. When set to True, all\n        Apprise Config files marked to be in STRICT mode are treated as being\n        in ALWAYS mode.\n\n        Take a file:// based configuration for example, only a file:// based\n        configuration can import another file:// based one. because it is set\n        to STRICT mode. If an http:// based configuration file attempted to\n        import a file:// one it woul fail. However this import would be\n        possible if insecure_includes is set to True.\n\n        There are cases where a self hosting apprise developer may wish to load\n        configuration from memory (in a string format) that contains import\n        entries (even file:// based ones).  In these circumstances if you want\n        these includes to be honored, this value must be set to True.\n        \"\"\"\n\n        # Initialize a server list of URLs\n        self.configs = []\n\n        # Prepare our Asset Object\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        # Set our cache flag\n        self.cache = cache\n\n        # Initialize our recursion value\n        self.recursion = recursion\n\n        # Initialize our insecure_includes flag\n        self.insecure_includes = insecure_includes\n\n        if paths is not None:\n            # Store our path(s)\n            self.add(paths)\n\n        return\n\n    def add(\n        self,\n        configs: str | ConfigBase | list[str | ConfigBase],\n        asset: AppriseAsset | None = None,\n        tag: str | list[str] | None = None,\n        cache: bool | int = True,\n        recursion: int | None = None,\n        insecure_includes: bool | None = None,\n    ) -> bool:\n        \"\"\"Adds one or more config URLs into our list.\n\n        You can override the global asset if you wish by including it with the\n        config(s) that you add.\n\n        By default we cache our responses so that subsiquent calls does not\n        cause the content to be retrieved again. Setting this to False does\n        mean more then one call can be made to retrieve the (same) data.  This\n        method can be somewhat inefficient if disabled and you're set up to\n        make remote calls.  Only disable caching if you understand the\n        consequences.\n\n        You can alternatively set the cache value to an int identifying the\n        number of seconds the previously retrieved can exist for before it\n        should be considered expired.\n\n        It's also worth nothing that the cache value is only set to elements\n        that are not already of subclass ConfigBase()\n\n        Optionally override the default recursion value.\n\n        Optionally override the insecure_includes flag. if insecure_includes is\n        set to True then all plugins that are set to a STRICT mode will be a\n        treated as ALWAYS.\n        \"\"\"\n\n        # Initialize our return status\n        return_status = True\n\n        # Initialize our default cache value\n        cache = cache if cache is not None else self.cache\n\n        # Initialize our default recursion value\n        recursion = recursion if recursion is not None else self.recursion\n\n        # Initialize our default insecure_includes value\n        insecure_includes = (\n            insecure_includes\n            if insecure_includes is not None\n            else self.insecure_includes\n        )\n\n        if asset is None:\n            # prepare default asset\n            asset = self.asset\n\n        if isinstance(configs, ConfigBase):\n            # Go ahead and just add our configuration into our list\n            self.configs.append(configs)\n            return True\n\n        elif isinstance(configs, str):\n            # Save our path\n            configs = (configs,)\n\n        elif not isinstance(configs, (tuple, set, list)):\n            logger.error(\n                f\"An invalid configuration path (type={type(configs)}) was \"\n                \"specified.\"\n            )\n            return False\n\n        # Iterate over our configuration\n        for config in configs:\n\n            if isinstance(config, ConfigBase):\n                # Go ahead and just add our configuration into our list\n                self.configs.append(config)\n                continue\n\n            elif not isinstance(config, str):\n                logger.warning(\n                    f\"An invalid configuration (type={type(config)}) was\"\n                    \" specified.\"\n                )\n                return_status = False\n                continue\n\n            logger.debug(f\"Loading configuration: {config}\")\n\n            # Instantiate ourselves an object, this function throws or\n            # returns None if it fails\n            instance = AppriseConfig.instantiate(\n                config,\n                asset=asset,\n                tag=tag,\n                cache=cache,\n                recursion=recursion,\n                insecure_includes=insecure_includes,\n            )\n            if not isinstance(instance, ConfigBase):\n                return_status = False\n                continue\n\n            # Add our initialized plugin to our server listings\n            self.configs.append(instance)\n\n        # Return our status\n        return return_status\n\n    def add_config(\n        self,\n        content: str,\n        asset: AppriseAsset | None = None,\n        tag: str | list[str] | None = None,\n        format: str | None = None,\n        recursion: int | None = None,\n        insecure_includes: bool | None = None,\n    ) -> bool:\n        \"\"\"Adds one configuration file in it's raw format. Content gets loaded\n        as a memory based object and only exists for the life of this\n        AppriseConfig object it was loaded into.\n\n        If you know the format ('yaml' or 'text') you can specify it for\n        slightly less overhead during this call.  Otherwise the configuration\n        is auto-detected.\n\n        Optionally override the default recursion value.\n\n        Optionally override the insecure_includes flag. if insecure_includes is\n        set to True then all plugins that are set to a STRICT mode will be a\n        treated as ALWAYS.\n        \"\"\"\n\n        # Initialize our default recursion value\n        recursion = recursion if recursion is not None else self.recursion\n\n        # Initialize our default insecure_includes value\n        insecure_includes = (\n            insecure_includes\n            if insecure_includes is not None\n            else self.insecure_includes\n        )\n\n        if asset is None:\n            # prepare default asset\n            asset = self.asset\n\n        if not isinstance(content, str):\n            logger.warning(\n                f\"An invalid configuration (type={type(content)}) was\"\n                \" specified.\"\n            )\n            return False\n\n        logger.debug(f\"Loading raw configuration: {content}\")\n\n        # Create ourselves a ConfigMemory Object to store our configuration\n        instance = C_MGR[\"memory\"](\n            content=content,\n            format=format,\n            asset=asset,\n            tag=tag,\n            recursion=recursion,\n            insecure_includes=insecure_includes,\n        )\n\n        if not (instance.config_format and\n                instance.config_format.value in common.CONFIG_FORMATS):\n            logger.warning(\n                \"The format of the configuration could not be detected.\"\n            )\n            return False\n\n        # Add our initialized plugin to our server listings\n        self.configs.append(instance)\n\n        # Return our status\n        return True\n\n    def servers(\n        self,\n        tag: str | list[str] = common.MATCH_ALL_TAG,\n        match_always: bool = True,\n        *args: Any,\n        **kwargs: Any,\n    ) -> list[NotifyBase]:\n        \"\"\"Returns all of our servers dynamically build based on parsed\n        configuration.\n\n        If a tag is specified, it applies to the configuration sources\n        themselves and not the notification services inside them.\n\n        This is for filtering the configuration files polled for results.\n\n        If the anytag is set, then any notification that is found set with that\n        tag are included in the response.\n        \"\"\"\n\n        # A match_always flag allows us to pick up on our 'any' keyword\n        # and notify these services under all circumstances\n        match_always = common.MATCH_ALWAYS_TAG if match_always else None\n\n        # Build our tag setup\n        #   - top level entries are treated as an 'or'\n        #   - second level (or more) entries are treated as 'and'\n        #\n        #   examples:\n        #     tag=\"tagA, tagB\"                = tagA or tagB\n        #     tag=['tagA', 'tagB']            = tagA or tagB\n        #     tag=[('tagA', 'tagC'), 'tagB']  = (tagA and tagC) or tagB\n        #     tag=[('tagB', 'tagC')]          = tagB and tagC\n\n        response = []\n\n        for entry in self.configs:\n\n            # Apply our tag matching based on our defined logic\n            if is_exclusive_match(\n                logic=tag,\n                data=entry.tags,\n                match_all=common.MATCH_ALL_TAG,\n                match_always=match_always,\n            ):\n                # Build ourselves a list of services dynamically and return the\n                # as a list\n                response.extend(entry.servers())\n\n        return response\n\n    @staticmethod\n    def instantiate(\n        url: str,\n        asset: AppriseAsset | None = None,\n        tag: str | list[str] | None = None,\n        cache: bool | int | None = None,\n        recursion: int = 0,\n        insecure_includes: bool = False,\n        suppress_exceptions: bool = True,\n    ) -> ConfigBase | None:\n        \"\"\"Returns the instance of a instantiated configuration plugin based on\n        the provided Config URL.\n\n        If the url fails to be parsed, then None is returned.\n        \"\"\"\n        # Attempt to acquire the schema at the very least to allow our\n        # configuration based urls.\n        schema = GET_SCHEMA_RE.match(url)\n        if schema is None:\n            # Plan B is to assume we're dealing with a file\n            schema = \"file\"\n            url = f\"{schema}://{URLBase.quote(url)}\"\n\n        else:\n            # Ensure our schema is always in lower case\n            schema = schema.group(\"schema\").lower()\n\n            # Some basic validation\n            if schema not in C_MGR:\n                logger.warning(f\"Unsupported schema {schema}.\")\n                return None\n\n        # Parse our url details of the server object as dictionary containing\n        # all of the information parsed from our URL\n        results = C_MGR[schema].parse_url(url)\n\n        if not results:\n            # Failed to parse the server URL\n            logger.warning(f\"Unparseable URL {url}.\")\n            return None\n\n        # Build a list of tags to associate with the newly added notifications\n        results[\"tag\"] = set(parse_list(tag))\n\n        # Prepare our Asset Object\n        results[\"asset\"] = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        if cache is not None:\n            # Force an over-ride of the cache value to what we have specified\n            results[\"cache\"] = cache\n\n        # Recursion can never be parsed from the URL\n        results[\"recursion\"] = recursion\n\n        # Insecure includes flag can never be parsed from the URL\n        results[\"insecure_includes\"] = insecure_includes\n\n        if suppress_exceptions:\n            try:\n                # Attempt to create an instance of our plugin using the parsed\n                # URL information\n                cfg_plugin = C_MGR[results[\"schema\"]](**results)\n\n            except Exception:\n                # the arguments are invalid or can not be used.\n                logger.warning(f\"Could not load URL: {url}\")\n                return None\n\n        else:\n            # Attempt to create an instance of our plugin using the parsed\n            # URL information but don't wrap it in a try catch\n            cfg_plugin = C_MGR[results[\"schema\"]](**results)\n\n        return cfg_plugin\n\n    def clear(self) -> None:\n        \"\"\"Empties our configuration list.\"\"\"\n        self.configs[:] = []\n\n    def server_pop(self, index: int) -> NotifyBase:\n        \"\"\"Removes an indexed Apprise Notification from the servers.\"\"\"\n\n        # Tracking variables\n        prev_offset = -1\n        offset = prev_offset\n\n        for entry in self.configs:\n            servers = entry.servers(cache=True)\n            if len(servers) > 0:\n                # Acquire a new maximum offset to work with\n                offset = prev_offset + len(servers)\n\n                if offset >= index:\n                    # we can pop an notification from our config stack\n                    return entry.pop(\n                        index\n                        if prev_offset == -1\n                        else (index - prev_offset - 1)\n                    )\n\n                # Update our old offset\n                prev_offset = offset\n\n        # If we reach here, then we indexed out of range\n        raise IndexError(\"list index out of range\")\n\n    def pop(self, index: int = -1) -> ConfigBase:\n        \"\"\"Removes an indexed Apprise Configuration from the stack and returns\n        it.\n\n        By default, the last element is removed from the list\n        \"\"\"\n        # Remove our entry\n        return self.configs.pop(index)\n\n    def __getitem__(self, index: int) -> ConfigBase:\n        \"\"\"Returns the indexed config entry of a loaded apprise\n        configuration.\"\"\"\n        return self.configs[index]\n\n    def __bool__(self) -> bool:\n        \"\"\"Allows the Apprise object to be wrapped in an 'if statement'.\n\n        True is returned if at least one service has been loaded.\n        \"\"\"\n        return bool(self.configs)\n\n    def __iter__(self):  # type: () -> Iterator[ConfigBase]\n        \"\"\"Returns an iterator to our config list.\"\"\"\n        return iter(self.configs)\n\n    def __len__(self) -> int:\n        \"\"\"Returns the number of config entries loaded.\"\"\"\n        return len(self.configs)\n"
  },
  {
    "path": "apprise/asset.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, tzinfo\nfrom os.path import abspath, dirname, isfile, join\nimport re\nfrom typing import Any, Optional, Union\nfrom uuid import uuid4\n\nfrom .common import (\n    NotifyFormat,\n    NotifyImageSize,\n    NotifyType,\n    PersistentStoreMode,\n)\nfrom .manager_plugins import NotificationManager\nfrom .utils.time import zoneinfo\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\nclass AppriseAsset:\n    \"\"\"Provides a supplimentary class that can be used to provide extra\n    information and details that can be used by Apprise such as providing an\n    alternate location to where images/icons can be found and the URL masks.\n\n    Any variable that starts with an underscore (_) can only be initialized by\n    this class manually and will/can not be parsed from a configuration file.\n    \"\"\"\n\n    # Application Identifier\n    app_id = \"Apprise\"\n\n    # Application Description\n    app_desc = \"Apprise Notifications\"\n\n    # Provider URL\n    app_url = \"https://github.com/caronc/apprise\"\n\n    # A Simple Mapping of Colors; For every NOTIFY_TYPE identified,\n    # there should be a mapping to it's color here:\n    html_notify_map = {\n        NotifyType.INFO: \"#3AA3E3\",\n        NotifyType.SUCCESS: \"#3AA337\",\n        NotifyType.FAILURE: \"#A32037\",\n        NotifyType.WARNING: \"#CACF29\",\n    }\n\n    # The default color to return if a mapping isn't found in our table above\n    default_html_color = \"#888888\"\n\n    # Ascii Notification\n    ascii_notify_map = {\n        NotifyType.INFO: \"[i]\",\n        NotifyType.SUCCESS: \"[+]\",\n        NotifyType.FAILURE: \"[!]\",\n        NotifyType.WARNING: \"[~]\",\n    }\n\n    # The default ascii to return if a mapping isn't found in our table above\n    default_ascii_chars = \"[?]\"\n\n    # The default image extension to use\n    default_extension = \".png\"\n\n    # The default image size if one isn't specified\n    default_image_size = NotifyImageSize.XY_256\n\n    # The default theme\n    theme = \"default\"\n\n    # Image URL Mask\n    image_url_mask = (\n        \"https://github.com/caronc/apprise/raw/master/apprise/assets/\"\n        \"themes/{THEME}/apprise-{TYPE}-{XY}{EXTENSION}\"\n    )\n\n    # Application Logo\n    image_url_logo = (\n        \"https://github.com/caronc/apprise/raw/master/apprise/assets/\"\n        \"themes/{THEME}/apprise-logo.png\"\n    )\n\n    # Image Path Mask\n    image_path_mask = abspath(\n        join(\n            dirname(__file__),\n            \"assets\",\n            \"themes\",\n            \"{THEME}\",\n            \"apprise-{TYPE}-{XY}{EXTENSION}\",\n        )\n    )\n\n    # This value can also be set on calls to Apprise.notify(). This allows\n    # you to let Apprise upfront the type of data being passed in.  This\n    # must be of type NotifyFormat. Possible values could be:\n    # - NotifyFormat.TEXT\n    # - NotifyFormat.MARKDOWN\n    # - NotifyFormat.HTML\n    # - None\n    #\n    # If no format is specified (hence None), then no special pre-formatting\n    # actions will take place during a notification. This has been and always\n    # will be the default.\n    body_format = None\n\n    # Always attempt to send notifications asynchronous (as the same time\n    # if possible)\n    # This is a Python 3 supported option only. If set to False, then\n    # notifications are sent sequentially (one after another)\n    async_mode = True\n\n    # Support :smile:, and other alike keywords swapping them for their\n    # unicode value. A value of None leaves the interpretation up to the\n    # end user to control (allowing them to specify emojis=yes on the\n    # URL)\n    interpret_emojis = None\n\n    # Whether or not to interpret escapes found within the input text prior\n    # to passing it upstream. Such as converting \\t to an actual tab and \\n\n    # to a new line.\n    interpret_escapes = False\n\n    # Defines the encoding of the content passed into Apprise\n    encoding = \"utf-8\"\n\n    # Automatically generate our Pretty Good Privacy (PGP) keys if one isn't\n    # present and our environment configuration allows for it.\n    # For example, a case where the environment wouldn't allow for it would be\n    # if Persistent Storage was set to `memory`\n    pgp_autogen = True\n\n    # Automatically generate our Privacy Enhanced Mail (PEM) keys if one isn't\n    # present and our environment configuration allows for it.\n    # For example, a case where the environment wouldn't allow for it would be\n    # if Persistent Storage was set to `memory`\n    pem_autogen = True\n\n    # For more detail see CWE-312 @\n    #    https://cwe.mitre.org/data/definitions/312.html\n    #\n    # By enabling this, the logging output has additional overhead applied to\n    # it preventing secure password and secret information from being\n    # displayed in the logging. Since there is overhead involved in performing\n    # this cleanup; system owners who run in a very isolated environment may\n    # choose to disable this for a slight performance bump. It is recommended\n    # that you leave this option as is otherwise.\n    secure_logging = True\n\n    # Optionally specify one or more path to attempt to scan for Python modules\n    # By default, no paths are scanned.\n    __plugin_paths = []\n\n    # Optionally set the location of the persistent storage\n    # By default there is no path and thus persistent storage is not used\n    __storage_path = None\n\n    # Optionally define the default salt to apply to all persistent storage\n    # namespace generation (unless over-ridden)\n    __storage_salt = b\"\"\n\n    # Optionally define the namespace length of the directories created by\n    # the storage. If this is set to zero, then the length is pre-determined\n    # by the generator (sha1, md5, sha256, etc)\n    __storage_idlen = 8\n\n    # Set storage to auto\n    __storage_mode = PersistentStoreMode.AUTO\n\n    # All internal/system flags are prefixed with an underscore (_)\n    # These can only be initialized using Python libraries and are not picked\n    # up from (yaml) configuration files (if set)\n\n    # An internal counter that is used by AppriseAPI\n    # (https://github.com/caronc/apprise-api). The idea is to allow one\n    # instance of AppriseAPI to call another, but to track how many times\n    # this occurs. It's intent is to prevent a loop where an AppriseAPI\n    # Server calls itself (or loops indefinitely)\n    _recursion = 0\n\n    # A unique identifer we can use to associate our calling source\n    _uid = str(uuid4())\n\n    # Default timezone to use (pass in timezone value)\n    # A list of timezones can be found here:\n    # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones\n    # You can specify things such as 'America/Montreal'\n    # If no timezone is specified, then the one detected on the system\n    # is uzed\n    _tzinfo = None\n\n    def __init__(\n        self,\n        plugin_paths: Optional[list[str]] = None,\n        storage_path: Optional[str] = None,\n        storage_mode: Optional[Union[str, PersistentStoreMode]] = None,\n        storage_salt: Optional[Union[str, bytes]] = None,\n        storage_idlen: Optional[int] = None,\n        timezone: Optional[Union[str, tzinfo]] = None,\n        **kwargs: Any\n    ) -> None:\n        \"\"\"Asset Initialization.\"\"\"\n        # Assign default arguments if specified\n        for key, value in kwargs.items():\n            if not hasattr(AppriseAsset, key):\n                raise AttributeError(\n                    f\"AppriseAsset init(): An invalid key {key} was specified.\"\n                )\n\n            setattr(self, key, value)\n\n        if plugin_paths:\n            # Load any decorated modules if defined\n            self.__plugin_paths = plugin_paths\n            N_MGR.module_detection(plugin_paths)\n\n        if storage_path:\n            # Define our persistent storage path\n            self.__storage_path = storage_path\n\n        if storage_mode:\n            # Define how our persistent storage behaves\n            try:\n                self.__storage_mode = (\n                    storage_mode if isinstance(storage_mode, NotifyFormat)\n                    else PersistentStoreMode(storage_mode.lower())\n                )\n\n            except (AttributeError, ValueError, TypeError):\n                err = (\n                    f\"An invalid persistent store mode ({storage_mode}) was \"\n                    \"specified.\")\n                raise AttributeError(err) from None\n\n        if isinstance(storage_idlen, int):\n            # Define the number of characters utilized from our namespace lengh\n            if storage_idlen < 0:\n                # Unsupported type\n                raise ValueError(\n                    \"AppriseAsset storage_idlen(): Value must \"\n                    \"be an integer and > 0\"\n                )\n\n            # Store value\n            self.__storage_idlen = storage_idlen\n\n        if isinstance(timezone, tzinfo):\n            self._tzinfo = timezone\n\n        elif timezone is not None:\n            self._tzinfo = zoneinfo(timezone)\n            if not self._tzinfo:\n                raise AttributeError(\n                    \"AppriseAsset timezone provided is invalid\") from None\n        else:\n            # Default our timezone to what is detected on the system\n            self._tzinfo = datetime.now().astimezone().tzinfo\n\n        if storage_salt is not None:\n            # Define the number of characters utilized from our namespace lengh\n\n            if isinstance(storage_salt, bytes):\n                self.__storage_salt = storage_salt\n\n            elif isinstance(storage_salt, str):\n                try:\n                    self.__storage_salt = storage_salt.encode(self.encoding)\n\n                except UnicodeEncodeError:\n                    # Bad data; don't pass it along\n                    raise ValueError(\n                        \"AppriseAsset namespace_salt(): \"\n                        \"Value provided could not be encoded\"\n                    ) from None\n\n            else:  # Unsupported\n                raise ValueError(\n                    \"AppriseAsset namespace_salt(): Value provided must be \"\n                    \"string or bytes object\"\n                )\n\n    def color(\n        self,\n        notify_type: NotifyType,\n        color_type: Optional[type] = None,\n    ) -> Union[str, int, tuple[int, int, int]]:\n        \"\"\"Returns an HTML mapped color based on passed in notify type.\n\n        if color_type is:\n           None    then a standard hex string is returned as\n                   a string format ('#000000').\n\n           int     then the integer representation is returned\n           tuple   then the the red, green, blue is returned in a tuple\n        \"\"\"\n\n        # Attempt to get the type, otherwise return a default grey\n        # if we couldn't look up the entry\n        color = self.html_notify_map.get(\n            notify_type, self.default_html_color)\n        if color_type is None:\n            # This is the default return type\n            return color\n\n        elif color_type is int:\n            # Convert the color to integer\n            return AppriseAsset.hex_to_int(color)\n\n        # The only other type is tuple\n        elif color_type is tuple:\n            return AppriseAsset.hex_to_rgb(color)\n\n        # Unsupported type\n        raise ValueError(\n            \"AppriseAsset html_color(): An invalid color_type was specified.\"\n        )\n\n    def ascii(self, notify_type: NotifyType) -> str:\n        \"\"\"Returns an ascii representation based on passed in notify type.\"\"\"\n        # look our response up\n        return self.ascii_notify_map.get(\n            notify_type, self.default_ascii_chars)\n\n    def image_url(\n        self,\n        notify_type: NotifyType,\n        image_size: Optional[NotifyImageSize] = None,\n        logo: bool = False,\n        extension: Optional[str] = None,\n    ) -> Optional[str]:\n        \"\"\"Apply our mask to our image URL.\n\n        if logo is set to True, then the logo_url is used instead\n        \"\"\"\n\n        url_mask = self.image_url_logo if logo else self.image_url_mask\n        if not url_mask:\n            # No image to return\n            return None\n\n        if extension is None:\n            extension = self.default_extension\n\n        if image_size is None:\n            image_size = self.default_image_size\n\n        re_map = {\n            \"{THEME}\": self.theme if self.theme else \"\",\n            \"{TYPE}\": notify_type.value,\n            \"{XY}\": image_size.value,\n            \"{EXTENSION}\": extension,\n        }\n\n        # Iterate over above list and store content accordingly\n        re_table = re.compile(\n            r\"(\" + \"|\".join(re_map.keys()) + r\")\",\n            re.IGNORECASE,\n        )\n\n        return re_table.sub(lambda x: re_map[x.group()], url_mask)\n\n    def image_path(\n        self,\n        notify_type: NotifyType,\n        image_size: NotifyImageSize,\n        must_exist: bool = True,\n        extension: Optional[str] = None,\n    ) -> Optional[str]:\n        \"\"\"Apply our mask to our image file path.\"\"\"\n\n        if not self.image_path_mask:\n            # No image to return\n            return None\n\n        if extension is None:\n            extension = self.default_extension\n\n        re_map = {\n            \"{THEME}\": self.theme if self.theme else \"\",\n            \"{TYPE}\": notify_type.value,\n            \"{XY}\": image_size.value,\n            \"{EXTENSION}\": extension,\n        }\n\n        # Iterate over above list and store content accordingly\n        re_table = re.compile(\n            r\"(\" + \"|\".join(re_map.keys()) + r\")\",\n            re.IGNORECASE,\n        )\n\n        # Acquire our path\n        path = re_table.sub(lambda x: re_map[x.group()], self.image_path_mask)\n        if must_exist and not isfile(path):\n            return None\n\n        # Return what we parsed\n        return path\n\n    def image_raw(\n        self,\n        notify_type: NotifyType,\n        image_size: NotifyImageSize,\n        extension: Optional[str] = None,\n    ) -> Optional[bytes]:\n        \"\"\"Returns the raw image if it can (otherwise the function returns\n        None)\"\"\"\n\n        path = self.image_path(\n            notify_type=notify_type,\n            image_size=image_size,\n            extension=extension,\n        )\n        if path:\n            try:\n                with open(path, \"rb\") as fd:\n                    return fd.read()\n\n            except OSError:\n                # We can't access the file\n                return None\n\n        return None\n\n    def details(self) -> dict[str, str]:\n        \"\"\"Returns the details associated with the AppriseAsset object.\"\"\"\n        return {\n            \"app_id\": self.app_id,\n            \"app_desc\": self.app_desc,\n            \"default_extension\": self.default_extension,\n            \"theme\": self.theme,\n            \"image_path_mask\": self.image_path_mask,\n            \"image_url_mask\": self.image_url_mask,\n            \"image_url_logo\": self.image_url_logo,\n        }\n\n    @staticmethod\n    def hex_to_rgb(value: str) -> tuple[int, int, int]:\n        \"\"\"Takes a hex string (such as #00ff00) and returns a tuple in the form\n        of (red, green, blue)\n\n        eg: #00ff00 becomes : (0, 65535, 0)\n        \"\"\"\n        value = value.lstrip(\"#\")\n        lv = len(value)\n        return tuple(\n            int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3)\n        )\n\n    @staticmethod\n    def hex_to_int(value: str) -> int:\n        \"\"\"Takes a hex string (such as #00ff00) and returns its integer\n        equivalent.\n\n        eg: #00000f becomes : 15\n        \"\"\"\n        return int(value.lstrip(\"#\"), 16)\n\n    @property\n    def plugin_paths(self) -> list[str]:\n        \"\"\"Return the plugin paths defined.\"\"\"\n        return self.__plugin_paths\n\n    @property\n    def storage_path(self) -> Optional[str]:\n        \"\"\"Return the persistent storage path defined.\"\"\"\n        return self.__storage_path\n\n    @property\n    def storage_mode(self) -> PersistentStoreMode:\n        \"\"\"Return the persistent storage mode defined.\"\"\"\n\n        return self.__storage_mode\n\n    @property\n    def storage_salt(self) -> bytes:\n        \"\"\"Return the provided namespace salt; this is always of type bytes.\"\"\"\n        return self.__storage_salt\n\n    @property\n    def storage_idlen(self) -> int:\n        \"\"\"Return the persistent storage id length.\"\"\"\n\n        return self.__storage_idlen\n\n    @property\n    def tzinfo(self) -> tzinfo:\n        \"\"\"Return the timezone object\"\"\"\n        return self._tzinfo\n"
  },
  {
    "path": "apprise/assets/NotifyXML-1.0.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<xs:schema attributeFormDefault=\"unqualified\" elementFormDefault=\"qualified\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\n  <xs:import namespace=\"http://schemas.xmlsoap.org/soap/envelope/\" schemaLocation=\"http://schemas.xmlsoap.org/soap/envelope/\"/>\n  <xs:element name=\"Notification\">\n    <xs:complexType>\n      <xs:sequence>\n        <xs:element name=\"Version\" type=\"xs:string\" />\n        <xs:element name=\"Subject\" type=\"xs:string\" />\n        <xs:element name=\"MessageType\">\n          <xs:simpleType>\n            <xs:restriction base=\"xs:string\">\n              <xs:enumeration value=\"success\" />\n              <xs:enumeration value=\"failure\" />\n              <xs:enumeration value=\"info\" />\n              <xs:enumeration value=\"warning\" />\n            </xs:restriction>\n          </xs:simpleType>\n        </xs:element>\n        <xs:element name=\"Message\" type=\"xs:string\" />\n      </xs:sequence>\n    </xs:complexType>\n  </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "apprise/assets/NotifyXML-1.1.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<xs:schema attributeFormDefault=\"unqualified\" elementFormDefault=\"qualified\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\">\n  <xs:import namespace=\"http://schemas.xmlsoap.org/soap/envelope/\" schemaLocation=\"http://schemas.xmlsoap.org/soap/envelope/\"/>\n  <xs:element name=\"Notification\">\n    <xs:complexType>\n      <xs:sequence>\n        <xs:element name=\"Version\" type=\"xs:string\" />\n        <xs:element name=\"Subject\" type=\"xs:string\" />\n        <xs:element name=\"MessageType\">\n          <xs:simpleType>\n            <xs:restriction base=\"xs:string\">\n              <xs:enumeration value=\"success\" />\n              <xs:enumeration value=\"failure\" />\n              <xs:enumeration value=\"info\" />\n              <xs:enumeration value=\"warning\" />\n            </xs:restriction>\n          </xs:simpleType>\n        </xs:element>\n        <xs:element name=\"Message\" type=\"xs:string\" />\n        <xs:element name=\"Attachments\" minOccurs=\"0\">\n          <xs:complexType>\n            <xs:sequence>\n              <xs:element name=\"Attachment\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:complexType>\n                  <xs:simpleContent>\n                    <xs:extension base=\"xs:string\">\n                      <xs:attribute name=\"mimetype\" type=\"xs:string\" use=\"required\"/>\n                      <xs:attribute name=\"filename\" type=\"xs:string\" use=\"required\"/>\n                    </xs:extension>\n                  </xs:simpleContent>\n                </xs:complexType>\n              </xs:element> \n            </xs:sequence>\n            <xs:attribute name=\"encoding\" type=\"xs:string\" use=\"required\"/>\n          </xs:complexType>\n        </xs:element>\n      </xs:sequence>\n    </xs:complexType>\n  </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "apprise/attachment/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Used for testing\nfrom ..manager_attachment import AttachmentManager\nfrom .base import AttachBase\n\n# Initalize our Attachment Manager Singleton\nA_MGR = AttachmentManager()\n\n__all__ = [\n    # Reference\n    \"AttachBase\",\n    \"AttachmentManager\",\n]\n"
  },
  {
    "path": "apprise/attachment/base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport base64\nimport contextlib\nimport mimetypes\nimport os\nimport time\n\nfrom .. import exception\nfrom ..common import ContentLocation\nfrom ..locale import gettext_lazy as _\nfrom ..url import URLBase\nfrom ..utils.parse import parse_bool\n\n\nclass AttachBase(URLBase):\n    \"\"\"This is the base class for all supported attachment types.\"\"\"\n\n    # For attachment type detection; this amount of data is read into memory\n    # 128KB (131072B)\n    max_detect_buffer_size = 131072\n\n    # Unknown mimetype\n    unknown_mimetype = \"application/octet-stream\"\n\n    # Our filename when we can't otherwise determine one\n    unknown_filename = \"apprise-attachment\"\n\n    # Our filename extension when we can't otherwise determine one\n    unknown_filename_extension = \".obj\"\n\n    # The strict argument is a flag specifying whether the list of known MIME\n    # types is limited to only the official types registered with IANA. When\n    # strict is True, only the IANA types are supported; when strict is False\n    # (the default), some additional non-standard but commonly used MIME types\n    # are also recognized.\n    strict = False\n\n    # The maximum file-size we will accept for an attachment size. If this is\n    # set to zero (0), then no check is performed\n    # 1 MB = 1048576 bytes\n    # 5 MB = 5242880 bytes\n    # 1 GB = 1048576000 bytes\n    max_file_size = 1048576000\n\n    # By default all attachments types are inaccessible.\n    # Developers of items identified in the attachment plugin directory\n    # are requried to set a location\n    location = ContentLocation.INACCESSIBLE\n\n    # Here is where we define all of the arguments we accept on the url\n    # such as: schema://whatever/?overflow=upstream&format=text\n    # These act the same way as tokens except they are optional and/or\n    # have default values set if mandatory. This rule must be followed\n    template_args = {\n        \"cache\": {\n            \"name\": _(\"Cache Age\"),\n            \"type\": \"int\",\n            # We default to (600) which means we cache for 10 minutes\n            \"default\": 600,\n        },\n        \"mime\": {\n            \"name\": _(\"Forced Mime Type\"),\n            \"type\": \"string\",\n        },\n        \"name\": {\n            \"name\": _(\"Forced File Name\"),\n            \"type\": \"string\",\n        },\n        \"verify\": {\n            \"name\": _(\"Verify SSL\"),\n            # SSL Certificate Authority Verification\n            \"type\": \"bool\",\n            # Provide a default\n            \"default\": True,\n        },\n    }\n\n    def __init__(self, name=None, mimetype=None, cache=None, **kwargs):\n        \"\"\"Initialize some general logging and common server arguments that\n        will keep things consistent when working with the configurations that\n        inherit this class.\n\n        Optionally provide a filename to over-ride name associated with the\n        actual file retrieved (from where-ever).\n\n        The mime-type is automatically detected, but you can over-ride this by\n        explicitly stating what it should be.\n\n        By default we cache our responses so that subsiquent calls does not\n        cause the content to be retrieved again.  For local file references\n        this makes no difference at all.  But for remote content, this does\n        mean more then one call can be made to retrieve the (same) data.  This\n        method can be somewhat inefficient if disabled.  Only disable caching\n        if you understand the consequences.\n\n        You can alternatively set the cache value to an int identifying the\n        number of seconds the previously retrieved can exist for before it\n        should be considered expired.\n        \"\"\"\n\n        super().__init__(**kwargs)\n\n        if not mimetypes.inited:\n            # Ensure mimetypes has been initialized\n            mimetypes.init()\n\n        # Attach Filename (does not have to be the same as path)\n        self._name = name\n\n        # The mime type of the attached content.  This is detected if not\n        # otherwise specified.\n        self._mimetype = mimetype\n\n        # The detected_mimetype, this is only used as a fallback if the\n        # mimetype wasn't forced by the user\n        self.detected_mimetype = None\n\n        # The detected filename by calling child class. A detected filename\n        # is always used if no force naming was specified.\n        self.detected_name = None\n\n        # Absolute path to attachment\n        self.download_path = None\n\n        # Track open file pointers\n        self.__pointers = set()\n\n        # Set our cache flag; it can be True, False, None, or a (positive)\n        # integer... nothing else\n        if cache is not None:\n            try:\n                self.cache = cache if isinstance(cache, bool) else int(cache)\n\n            except (TypeError, ValueError):\n                err = f\"An invalid cache value ({cache}) was specified.\"\n                self.logger.warning(err)\n                raise TypeError(err) from None\n\n            # Some simple error checking\n            if self.cache < 0:\n                err = f\"A negative cache value ({cache}) was specified.\"\n                self.logger.warning(err)\n                raise TypeError(err)\n\n        else:\n            self.cache = None\n\n        # Validate mimetype if specified\n        if self._mimetype and (\n                next(\n                    (\n                        t\n                        for t in mimetypes.types_map.values()\n                        if self._mimetype == t\n                    ),\n                    None,\n                )\n                is None):\n            err = f\"An invalid mime-type ({mimetype}) was specified.\"\n            self.logger.warning(err)\n            raise TypeError(err)\n\n        return\n\n    @property\n    def path(self):\n        \"\"\"Returns the absolute path to the filename.\n\n        If this is not known or is know but has been considered expired (due to\n        cache setting), then content is re-retrieved prior to returning.\n        \"\"\"\n\n        if not self.exists():\n            # we could not obtain our path\n            return None\n\n        return self.download_path\n\n    @property\n    def name(self):\n        \"\"\"Returns the filename.\"\"\"\n        if self._name:\n            # return our fixed content\n            return self._name\n\n        if not self.exists():\n            # we could not obtain our name\n            return None\n\n        if not self.detected_name:\n            # If we get here, our download was successful but we don't have a\n            # filename based on our content.\n            ext = mimetypes.guess_extension(self.mimetype)\n            self.detected_name = (\n                f\"{self.unknown_filename}\"\n                f\"{ext if ext else self.unknown_filename_extension}\"\n            )\n\n        return self.detected_name\n\n    @property\n    def mimetype(self):\n        \"\"\"Returns mime type (if one is present).\n\n        Content is cached once determied to prevent overhead of future calls.\n        \"\"\"\n        if not self.exists():\n            # we could not obtain our attachment\n            return None\n\n        if self._mimetype:\n            # return our pre-calculated cached content\n            return self._mimetype\n\n        if not self.detected_mimetype:\n            # guess_type() returns: (type, encoding) and sets type to None\n            # if it can't otherwise determine it.\n            with contextlib.suppress(TypeError):\n                # Directly reference _name and detected_name to prevent\n                # recursion loop (as self.name calls this function)\n                self.detected_mimetype, _ = mimetypes.guess_type(\n                    self._name if self._name else self.detected_name,\n                    strict=self.strict,\n                )\n\n        # Return our mime type\n        return (\n            self.detected_mimetype\n            if self.detected_mimetype\n            else self.unknown_mimetype\n        )\n\n    def exists(self, retrieve_if_missing=True):\n        \"\"\"Simply returns true if the object has downloaded and stored the\n        attachment AND the attachment has not expired.\"\"\"\n        if self.location == ContentLocation.INACCESSIBLE:\n            # our content is inaccessible\n            return False\n\n        cache = (\n            self.template_args[\"cache\"][\"default\"]\n            if self.cache is None\n            else self.cache\n        )\n\n        try:\n            if (\n                self.download_path\n                and os.path.isfile(self.download_path)\n                and cache\n            ):\n\n                # We have enough reason to look further into our cached content\n                # and verify it has not expired.\n                if cache is True:\n                    # return our fixed content as is; we will always cache it\n                    return True\n\n                # Verify our cache time to determine whether we will get our\n                # content again.\n                age_in_sec = time.time() - os.stat(self.download_path).st_mtime\n                if age_in_sec <= cache:\n                    return True\n\n        except OSError:\n            # The file is not present\n            pass\n\n        return False if not retrieve_if_missing else self.download()\n\n    def base64(self, encoding=\"ascii\"):\n        \"\"\"Returns the attachment object as a base64 string otherwise None is\n        returned if an error occurs.\n\n        If encoding is set to None, then it is not encoded when returned\n        \"\"\"\n        if not self:\n            # We could not access the attachment\n            self.logger.error(\n                f\"Could not access attachment {self.url(privacy=True)}.\"\n            )\n            raise exception.AppriseFileNotFound(\"Attachment Missing\")\n\n        try:\n            with self.open() as f:\n                # Prepare our Attachment in Base64\n                return (\n                    base64.b64encode(f.read()).decode(encoding)\n                    if encoding\n                    else base64.b64encode(f.read())\n                )\n\n        except (FileNotFoundError):\n            # We no longer have a path to open\n            raise exception.AppriseFileNotFound(\"Attachment Missing\") from None\n\n        except (TypeError, OSError) as e:\n            self.logger.warning(\n                \"An I/O error occurred while reading {}.\".format(\n                    self.name if self else \"attachment\"\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            raise exception.AppriseDiskIOError(\n                \"Attachment Access Error\") from e\n\n    def invalidate(self):\n        \"\"\"Release any temporary data that may be open by child classes.\n        Externally fetched content should be automatically cleaned up when this\n        function is called.\n\n        This function should also reset the following entries to None:\n          - detected_name : Should identify a human readable filename\n          - download_path: Must contain a absolute path to content\n          - detected_mimetype: Should identify mimetype of content\n        \"\"\"\n\n        # Remove all open pointers\n        while self.__pointers:\n            self.__pointers.pop().close()\n\n        self.detected_name = None\n        self.download_path = None\n        self.detected_mimetype = None\n        return\n\n    def download(self):\n        \"\"\"This function must be over-ridden by inheriting classes.\n\n        Inherited classes MUST populate:\n          - detected_name: Should identify a human readable filename\n          - download_path: Must contain a absolute path to content\n          - detected_mimetype: Should identify mimetype of content\n\n        If a download fails, you should ensure these values are set to None.\n        \"\"\"\n        raise NotImplementedError(\n            \"download() is implimented by the child class.\"\n        )\n\n    def open(self, mode=\"rb\"):\n        \"\"\"Return our file pointer and track it (we'll auto close later)\"\"\"\n        pointer = open(self.path, mode=mode)  # noqa: SIM115\n        self.__pointers.add(pointer)\n        return pointer\n\n    def chunk(self, size=5242880):\n        \"\"\"A Generator that yield chunks of a file with the specified size.\n\n        By default the chunk size is set to 5MB (5242880 bytes)\n        \"\"\"\n\n        with self.open() as file:\n            while True:\n                chunk = file.read(size)\n                if not chunk:\n                    break\n\n                yield chunk\n\n    def __enter__(self):\n        \"\"\"Support with keyword.\"\"\"\n        return self.open()\n\n    def __exit__(self, value_type, value, traceback):\n        \"\"\"Stub to do nothing; but support exit of with statement\n        gracefully.\"\"\"\n        return\n\n    @staticmethod\n    def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True):\n        \"\"\"Parses the URL and returns it broken apart into a dictionary.\n\n        This is very specific and customized for Apprise.\n\n        Args:\n            url (str): The URL you want to fully parse.\n            verify_host (:obj:`bool`, optional): a flag kept with the parsed\n                 URL which some child classes will later use to verify SSL\n                 keys (if SSL transactions take place).  Unless under very\n                 specific circumstances, it is strongly recomended that\n                 you leave this default value set to True.\n\n        Returns:\n            A dictionary is returned containing the URL fully parsed if\n            successful, otherwise None is returned.\n        \"\"\"\n\n        results = URLBase.parse_url(\n            url, verify_host=verify_host, sanitize=sanitize\n        )\n\n        if not results:\n            # We're done; we failed to parse our url\n            return results\n\n        # Allow overriding the default config mime type\n        if \"mime\" in results[\"qsd\"]:\n            results[\"mimetype\"] = (\n                results[\"qsd\"].get(\"mime\", \"\").strip().lower()\n            )\n\n        # Allow overriding the default file name\n        if \"name\" in results[\"qsd\"]:\n            results[\"name\"] = results[\"qsd\"].get(\"name\", \"\").strip().lower()\n\n        # Our cache value\n        if \"cache\" in results[\"qsd\"]:\n            # First try to get it's integer value\n            try:\n                results[\"cache\"] = int(results[\"qsd\"][\"cache\"])\n\n            except (ValueError, TypeError):\n                # No problem, it just isn't an integer; now treat it as a bool\n                # instead:\n                results[\"cache\"] = parse_bool(results[\"qsd\"][\"cache\"])\n\n        return results\n\n    def __len__(self):\n        \"\"\"Returns the filesize of the attachment.\"\"\"\n        if not self:\n            return 0\n\n        try:\n            return os.path.getsize(self.path) if self.path else 0\n\n        except OSError:\n            # OSError can occur if the file is inaccessible\n            return 0\n\n    def __bool__(self):\n        \"\"\"Allows the Apprise object to be wrapped in an based 'if statement'.\n\n        True is returned if our content was downloaded correctly.\n        \"\"\"\n        return bool(self.path)\n\n    def __del__(self):\n        \"\"\"Perform any house cleaning.\"\"\"\n        self.invalidate()\n"
  },
  {
    "path": "apprise/attachment/file.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport os\nimport re\n\nfrom ..common import ContentLocation\nfrom ..locale import gettext_lazy as _\nfrom ..utils.disk import path_decode\nfrom .base import AttachBase\n\n\nclass AttachFile(AttachBase):\n    \"\"\"A wrapper for File based attachment sources.\"\"\"\n\n    # The default descriptive name associated with the service\n    service_name = _(\"Local File\")\n\n    # The default protocol\n    protocol = \"file\"\n\n    # Content is local to the same location as the apprise instance\n    # being called (server-side)\n    location = ContentLocation.LOCAL\n\n    def __init__(self, path, **kwargs):\n        \"\"\"Initialize Local File Attachment Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Store path but mark it dirty since we have not performed any\n        # verification at this point.\n        self.dirty_path = path_decode(path)\n\n        # Track our file as it was saved\n        self.__original_path = os.path.normpath(path)\n        return\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {}\n\n        if self._mimetype:\n            # A mime-type was enforced\n            params[\"mime\"] = self._mimetype\n\n        if self._name:\n            # A name was enforced\n            params[\"name\"] = self._name\n\n        return \"file://{path}{params}\".format(\n            path=self.quote(self.__original_path),\n            params=(\n                \"?{}\".format(self.urlencode(params, safe=\"/\"))\n                if params\n                else \"\"\n            ),\n        )\n\n    def download(self, **kwargs):\n        \"\"\"Perform retrieval of our data.\n\n        For file base attachments, our data already exists, so we only need to\n        validate it.\n        \"\"\"\n\n        if self.location == ContentLocation.INACCESSIBLE:\n            # our content is inaccessible\n            return False\n\n        # Ensure any existing content set has been invalidated\n        self.invalidate()\n\n        try:\n            if not os.path.isfile(self.dirty_path):\n                return False\n\n        except OSError:\n            return False\n\n        if (\n            self.max_file_size > 0\n            and os.path.getsize(self.dirty_path) > self.max_file_size\n        ):\n\n            # The content to attach is to large\n            self.logger.error(\n                \"Content exceeds allowable maximum file length\"\n                f\" ({int(self.max_file_size / 1024)}KB):\"\n                f\" {self.url(privacy=True)}\"\n            )\n\n            # Return False (signifying a failure)\n            return False\n\n        # We're good to go if we get here. Set our minimum requirements of\n        # a call do download() before returning a success\n        self.download_path = self.dirty_path\n        self.detected_name = os.path.basename(self.download_path)\n\n        # We don't need to set our self.detected_mimetype as it can be\n        # pulled at the time it's needed based on the detected_name\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL so that we can handle all different file paths and\n        return it as our path object.\"\"\"\n\n        results = AttachBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early; it's not a good URL\n            return results\n\n        match = re.match(r\"file://(?P<path>[^?]+)(\\?.*)?\", url, re.I)\n        if not match:\n            return None\n\n        results[\"path\"] = AttachFile.unquote(match.group(\"path\"))\n        return results\n"
  },
  {
    "path": "apprise/attachment/http.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nimport os\nimport re\nfrom tempfile import NamedTemporaryFile\nimport threading\n\nimport requests\n\nfrom ..common import ContentLocation\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom .base import AttachBase\n\n\nclass AttachHTTP(AttachBase):\n    \"\"\"A wrapper for HTTP based attachment sources.\"\"\"\n\n    # The default descriptive name associated with the service\n    service_name = _(\"Web Based\")\n\n    # The default protocol\n    protocol = \"http\"\n\n    # The default secure protocol\n    secure_protocol = \"https\"\n\n    # The number of bytes in memory to read from the remote source at a time\n    chunk_size = 8192\n\n    # Web based requests are remote/external to our current location\n    location = ContentLocation.HOSTED\n\n    # thread safe loading\n    _lock = threading.Lock()\n\n    def __init__(self, headers=None, **kwargs):\n        \"\"\"Initialize HTTP Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.schema = \"https\" if self.secure else \"http\"\n\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"/\"\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        # Where our content is written to upon a call to download.\n        self._temp_file = None\n\n        # Our Query String Dictionary; we use this to track arguments\n        # specified that aren't otherwise part of this class\n        self.qsd = {\n            k: v\n            for k, v in kwargs.get(\"qsd\", {}).items()\n            if k not in self.template_args\n        }\n\n        return\n\n    def download(self, **kwargs):\n        \"\"\"Perform retrieval of the configuration based on the specified\n        request.\"\"\"\n\n        if self.location == ContentLocation.INACCESSIBLE:\n            # our content is inaccessible\n            return False\n\n        # prepare header\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        url = f\"{self.schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        url += self.fullpath\n\n        # Where our request object will temporarily live.\n        r = None\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        with self._lock:\n            if self.exists(retrieve_if_missing=False):\n                # Due to locking; it's possible a concurrent thread already\n                # handled the retrieval in which case we can safely move on\n                self.logger.trace(\n                    \"HTTP Attachment %s already retrieved\",\n                    self._temp_file.name,\n                )\n                return True\n\n            # Ensure any existing content set has been invalidated\n            self.invalidate()\n\n            self.logger.debug(\n                \"HTTP Attachment Fetch URL:\"\n                f\" {url} (cert_verify={self.verify_certificate!r})\"\n            )\n\n            try:\n                # Make our request\n                with requests.get(\n                    url,\n                    headers=headers,\n                    auth=auth,\n                    params=self.qsd,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                    stream=True,\n                ) as r:\n\n                    # Handle Errors\n                    r.raise_for_status()\n\n                    # Get our file-size (if known)\n                    try:\n                        file_size = int(r.headers.get(\"Content-Length\", \"0\"))\n                    except (TypeError, ValueError):\n                        # Handle edge case where Content-Length is a bad value\n                        file_size = 0\n\n                    # Perform a little Q/A on file limitations and restrictions\n                    if (\n                        self.max_file_size > 0\n                        and file_size > self.max_file_size\n                    ):\n\n                        # The content retrieved is to large\n                        self.logger.error(\n                            \"HTTP response exceeds allowable maximum file\"\n                            f\" length ({int(self.max_file_size / 1024)}KB):\"\n                            f\" {self.url(privacy=True)}\"\n                        )\n\n                        # Return False (signifying a failure)\n                        return False\n\n                    # Detect config format based on mime if the format isn't\n                    # already enforced\n                    self.detected_mimetype = r.headers.get(\"Content-Type\")\n\n                    d = r.headers.get(\"Content-Disposition\", \"\")\n                    result = re.search(\n                        r\"filename=['\\\"]?(?P<name>[^'\\\"]+)['\\\"]?\", d, re.I\n                    )\n                    if result:\n                        self.detected_name = result.group(\"name\").strip()\n\n                    # Create a temporary file to work with; delete must be set\n                    # to False or it isn't compatible with Microsoft Windows\n                    # instances. In lieu of this, __del__ will clean up the\n                    # file for us.\n                    self._temp_file = \\\n                        NamedTemporaryFile(delete=False)  # noqa: SIM115\n\n                    # Get our chunk size\n                    chunk_size = self.chunk_size\n\n                    # Track all bytes written to disk\n                    bytes_written = 0\n\n                    # If we get here, we can now safely write our content to\n                    # disk\n                    for chunk in r.iter_content(chunk_size=chunk_size):\n                        # filter out keep-alive chunks\n                        if chunk:\n                            self._temp_file.write(chunk)\n                            bytes_written = self._temp_file.tell()\n\n                            # Prevent a case where Content-Length isn't\n                            # provided. In this case we don't want to fetch\n                            # beyond our limits\n                            if self.max_file_size > 0:\n                                if bytes_written > self.max_file_size:\n                                    # The content retrieved is to large\n                                    self.logger.error(\n                                        \"HTTP response exceeds allowable\"\n                                        \" maximum file length\"\n                                        f\" ({int(self.max_file_size / 1024)}\"\n                                        f\"KB): {self.url(privacy=True)}\"\n                                    )\n\n                                    # Invalidate any variables previously set\n                                    self.invalidate()\n\n                                    # Return False (signifying a failure)\n                                    return False\n\n                                elif (\n                                    bytes_written + chunk_size\n                                    > self.max_file_size\n                                ):\n                                    # Adjust out next read to accommodate up to\n                                    # our limit +1. This will prevent us from\n                                    # reading to much into our memory buffer\n                                    self.max_file_size - bytes_written + 1\n\n                    # Ensure our content is flushed to disk for post-processing\n                    self._temp_file.flush()\n\n                    # Set our minimum requirements for a successful download()\n                    # call\n                    self.download_path = self._temp_file.name\n                    if not self.detected_name:\n                        self.detected_name = os.path.basename(self.fullpath)\n\n            except requests.RequestException as e:\n                self.logger.error(\n                    \"A Connection error occurred retrieving HTTP \"\n                    f\"configuration from {self.host}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Invalidate any variables previously set\n                self.invalidate()\n\n                # Return False (signifying a failure)\n                return False\n\n            except OSError:\n                # IOError is present for backwards compatibility with Python\n                # versions older then 3.3.  >= 3.3 throw OSError now.\n\n                # Could not open and/or write the temporary file\n                self.logger.error(\n                    \"Could not write attachment to disk:\"\n                    f\" {self.url(privacy=True)}\"\n                )\n\n                # Invalidate any variables previously set\n                self.invalidate()\n\n                # Return False (signifying a failure)\n                return False\n\n        # Return our success\n        return True\n\n    def invalidate(self):\n        \"\"\"Close our temporary file.\"\"\"\n        if self._temp_file:\n            self.logger.trace(\"Attachment cleanup of %s\", self._temp_file.name)\n            self._temp_file.close()\n\n            with contextlib.suppress(OSError):\n                # Ensure our file is removed (if it exists)\n                os.unlink(self._temp_file.name)\n\n            # Reset our temporary file to prevent from entering\n            # this block again\n            self._temp_file = None\n\n        super().invalidate()\n\n    def __del__(self):\n        \"\"\"Tidy memory if open.\"\"\"\n        self.invalidate()\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        # Prepare our cache value\n        if self.cache is not None:\n            if isinstance(self.cache, bool) or not self.cache:\n                cache = \"yes\" if self.cache else \"no\"\n            else:\n                cache = int(self.cache)\n\n            # Set our cache value\n            params[\"cache\"] = cache\n\n        if self._mimetype:\n            # A format was enforced\n            params[\"mime\"] = self._mimetype\n\n        if self._name:\n            # A name was enforced\n            params[\"name\"] = self._name\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Apply any remaining entries to our URL\n        params.update(self.qsd)\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=self.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=self.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}{fullpath}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            hostname=self.quote(self.host, safe=\"\"),\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=self.quote(self.fullpath, safe=\"/\"),\n            params=self.urlencode(params, safe=\"/\"),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = AttachBase.parse_url(url, sanitize=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set\n        results[\"headers\"] = results[\"qsd-\"]\n        results[\"headers\"].update(results[\"qsd+\"])\n\n        return results\n"
  },
  {
    "path": "apprise/attachment/memory.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport base64\nimport io\nimport os\nimport re\nimport uuid\n\nfrom .. import exception\nfrom ..common import ContentLocation\nfrom ..locale import gettext_lazy as _\nfrom .base import AttachBase\n\n\nclass AttachMemory(AttachBase):\n    \"\"\"A wrapper for Memory based attachment sources.\"\"\"\n\n    # The default descriptive name associated with the service\n    service_name = _(\"Memory\")\n\n    # The default protocol\n    protocol = \"memory\"\n\n    # Content is local to the same location as the apprise instance\n    # being called (server-side)\n    location = ContentLocation.LOCAL\n\n    def __init__(\n        self,\n        content=None,\n        name=None,\n        mimetype=None,\n        encoding=\"utf-8\",\n        **kwargs,\n    ):\n        \"\"\"Initialize Memory Based Attachment Object.\"\"\"\n        # Create our BytesIO object\n        self._data = io.BytesIO()\n\n        if content is None:\n            # Empty; do nothing\n            pass\n\n        elif isinstance(content, str):\n            content = content.encode(encoding)\n            if mimetype is None:\n                mimetype = \"text/plain\"\n\n            if not name:\n                # Generate a unique filename\n                name = str(uuid.uuid4()) + \".txt\"\n\n        elif not isinstance(content, bytes):\n            raise TypeError(\n                \"Provided content for memory attachment is invalid\"\n            )\n\n        # Store our content\n        if content:\n            self._data.write(content)\n\n        if mimetype is None:\n            # Default mimetype\n            mimetype = \"application/octet-stream\"\n\n        if not name:\n            # Generate a unique filename\n            name = str(uuid.uuid4()) + \".dat\"\n\n        # Initialize our base object\n        super().__init__(name=name, mimetype=mimetype, **kwargs)\n\n        return\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"mime\": self._mimetype,\n        }\n\n        return \"memory://{name}?{params}\".format(\n            name=self.quote(self._name),\n            params=self.urlencode(params, safe=\"/\"),\n        )\n\n    def open(self, *args, **kwargs):\n        \"\"\"Return our memory object.\"\"\"\n        # Return our object\n        self._data.seek(0, 0)\n        return self._data\n\n    def __enter__(self):\n        \"\"\"Support with clause.\"\"\"\n        # Return our object\n        self._data.seek(0, 0)\n        return self._data\n\n    def download(self, **kwargs):\n        \"\"\"Handle memory download() call.\"\"\"\n\n        if self.location == ContentLocation.INACCESSIBLE:\n            # our content is inaccessible\n            return False\n\n        if self.max_file_size > 0 and len(self) > self.max_file_size:\n            # The content to attach is to large\n            self.logger.error(\n                \"Content exceeds allowable maximum memory size\"\n                f\" ({int(self.max_file_size / 1024)}KB):\"\n                f\" {self.url(privacy=True)}\"\n            )\n\n            # Return False (signifying a failure)\n            return False\n\n        return True\n\n    def base64(self, encoding=\"ascii\"):\n        \"\"\"We need to over-ride this since the base64 sub-library seems to\n        close our file descriptor making it no longer referencable.\"\"\"\n\n        if not self:\n            # We could not access the attachment\n            self.logger.error(\n                f\"Could not access attachment {self.url(privacy=True)}.\"\n            )\n            raise exception.AppriseFileNotFound(\"Attachment Missing\")\n        self._data.seek(0, 0)\n\n        return (\n            base64.b64encode(self._data.read()).decode(encoding)\n            if encoding\n            else base64.b64encode(self._data.read())\n        )\n\n    def invalidate(self):\n        \"\"\"Removes data.\"\"\"\n        self._data.truncate(0)\n        return\n\n    def exists(self):\n        \"\"\"Over-ride exists() call.\"\"\"\n        size = len(self)\n        return bool(\n            self.location != ContentLocation.INACCESSIBLE\n            and size > 0\n            and (\n                self.max_file_size <= 0\n                or (self.max_file_size > 0 and size <= self.max_file_size)\n            )\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL so that we can handle all different file paths and\n        return it as our path object.\"\"\"\n\n        results = AttachBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early; it's not a good URL\n            return results\n\n        if \"name\" not in results:\n            # Allow fall-back to be from URL\n            match = re.match(r\"memory://(?P<path>[^?]+)(\\?.*)?\", url, re.I)\n            if match:\n                # Store our filename only (ignore any defined paths)\n                results[\"name\"] = os.path.basename(\n                    AttachMemory.unquote(match.group(\"path\"))\n                )\n        return results\n\n    @property\n    def path(self):\n        \"\"\"Return the filename.\"\"\"\n        if not self.exists():\n            # we could not obtain our path\n            return None\n\n        return self._name\n\n    def __len__(self):\n        \"\"\"Returns the size of he memory attachment.\"\"\"\n        return self._data.getbuffer().nbytes\n\n    def __bool__(self):\n        \"\"\"Allows the Apprise object to be wrapped in an based 'if statement'.\n\n        True is returned if our content was downloaded correctly.\n        \"\"\"\n\n        return self.exists()\n"
  },
  {
    "path": "apprise/cli.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport os\nfrom os.path import exists, isfile\nimport platform\nimport re\nimport shutil\nimport sys\nimport textwrap\n\nimport click\n\nfrom . import (\n    Apprise,\n    AppriseAsset,\n    AppriseConfig,\n    PersistentStore,\n    __copyright__,\n    __license__,\n    __title__,\n    __version__,\n)\nfrom .common import (\n    NOTIFY_FORMATS,\n    NOTIFY_TYPES,\n    PERSISTENT_STORE_MODES,\n    ContentLocation,\n    NotifyFormat,\n    NotifyType,\n    PersistentStoreMode,\n    PersistentStoreState,\n)\nfrom .logger import logger\nfrom .utils.disk import bytes_to_str, dir_size, path_decode\nfrom .utils.parse import parse_list\n\n# By default we allow looking 1 level down recursively in Apprise configuration\n# files.\nDEFAULT_RECURSION_DEPTH = 1\n\n# Default number of days to prune persistent storage\nDEFAULT_STORAGE_PRUNE_DAYS = int(\n    os.environ.get(\"APPRISE_STORAGE_PRUNE_DAYS\", 30)\n)\n\n# The default URL ID Length\nDEFAULT_STORAGE_UID_LENGTH = int(\n    os.environ.get(\"APPRISE_STORAGE_UID_LENGTH\", 8)\n)\n\n# Defines the environment variable to parse if defined. This is ONLY\n# Referenced if:\n# - No Configuration Files were found/loaded/specified\n# - No URLs were provided directly into the CLI Call\nDEFAULT_ENV_APPRISE_URLS = \"APPRISE_URLS\"\n\n# Defines the override path for the configuration files read\nDEFAULT_ENV_APPRISE_CONFIG_PATH = \"APPRISE_CONFIG_PATH\"\n\n# Defines the override path for the plugins to load\nDEFAULT_ENV_APPRISE_PLUGIN_PATH = \"APPRISE_PLUGIN_PATH\"\n\n# Defines the override path for the persistent storage\nDEFAULT_ENV_APPRISE_STORAGE_PATH = \"APPRISE_STORAGE_PATH\"\n\n# Defines our click context settings adding -h to the additional options that\n# can be specified to get the help menu to come up\nCONTEXT_SETTINGS = {\"help_option_names\": [\"-h\", \"--help\"]}\n\n# Define our default configuration we use if nothing is otherwise specified\nDEFAULT_CONFIG_PATHS = (\n    # Legacy Path Support\n    \"~/.apprise\",\n    \"~/.apprise.conf\",\n    \"~/.apprise.yml\",\n    \"~/.apprise.yaml\",\n    \"~/.config/apprise\",\n    \"~/.config/apprise.conf\",\n    \"~/.config/apprise.yml\",\n    \"~/.config/apprise.yaml\",\n    # Plugin Support Extended Directory Search Paths\n    \"~/.apprise/apprise\",\n    \"~/.apprise/apprise.conf\",\n    \"~/.apprise/apprise.yml\",\n    \"~/.apprise/apprise.yaml\",\n    \"~/.config/apprise/apprise\",\n    \"~/.config/apprise/apprise.conf\",\n    \"~/.config/apprise/apprise.yml\",\n    \"~/.config/apprise/apprise.yaml\",\n    # Global Configuration File Support\n    \"/etc/apprise\",\n    \"/etc/apprise.yml\",\n    \"/etc/apprise.yaml\",\n    \"/etc/apprise/apprise\",\n    \"/etc/apprise/apprise.conf\",\n    \"/etc/apprise/apprise.yml\",\n    \"/etc/apprise/apprise.yaml\",\n)\n\n# Define our paths to search for plugins\nDEFAULT_PLUGIN_PATHS = (\n    \"~/.apprise/plugins\",\n    \"~/.config/apprise/plugins\",\n    # Global Plugin Support\n    \"/var/lib/apprise/plugins\",\n)\n\n\n#\n# General Options and Defaults\n#\nDEFAULT_NOTIFY_TYPE = NotifyType.INFO\n\nNOTIFY_TYPE_CHOICES: tuple[NotifyType, ...] = (\n    NotifyType.INFO,\n    NotifyType.SUCCESS,\n    NotifyType.WARNING,\n    NotifyType.FAILURE,\n)\n\nDEFAULT_NOTIFY_FORMAT = NotifyFormat.TEXT\n\nNOTIFY_FORMAT_CHOICES: tuple[NotifyFormat, ...] = (\n    NotifyFormat.TEXT,\n    NotifyFormat.MARKDOWN,\n    NotifyFormat.HTML,\n)\n\n#\n# Persistent Storage\n#\nDEFAULT_STORAGE_PATH = \"~/.local/share/apprise/cache\"\n\n# Storage Mode\nDEFAULT_STORAGE_MODE = PersistentStoreMode.AUTO\n\n# Create an ordered list of options (first is default)\nPERSISTENT_STORE_MODE_CHOICES: tuple[PersistentStoreMode, ...] = (\n    PersistentStoreMode.AUTO,\n    PersistentStoreMode.FLUSH,\n    PersistentStoreMode.MEMORY,\n)\n\n# Detect Windows\nif platform.system() == \"Windows\":\n    # Default Config Search Path for Windows Users\n    DEFAULT_CONFIG_PATHS = (\n        \"%APPDATA%\\\\Apprise\\\\apprise\",\n        \"%APPDATA%\\\\Apprise\\\\apprise.conf\",\n        \"%APPDATA%\\\\Apprise\\\\apprise.yml\",\n        \"%APPDATA%\\\\Apprise\\\\apprise.yaml\",\n        \"%LOCALAPPDATA%\\\\Apprise\\\\apprise\",\n        \"%LOCALAPPDATA%\\\\Apprise\\\\apprise.conf\",\n        \"%LOCALAPPDATA%\\\\Apprise\\\\apprise.yml\",\n        \"%LOCALAPPDATA%\\\\Apprise\\\\apprise.yaml\",\n        #\n        # Global Support\n        #\n        # C:\\ProgramData\\Apprise\n        \"%ALLUSERSPROFILE%\\\\Apprise\\\\apprise\",\n        \"%ALLUSERSPROFILE%\\\\Apprise\\\\apprise.conf\",\n        \"%ALLUSERSPROFILE%\\\\Apprise\\\\apprise.yml\",\n        \"%ALLUSERSPROFILE%\\\\Apprise\\\\apprise.yaml\",\n        # C:\\Program Files\\Apprise\n        \"%PROGRAMFILES%\\\\Apprise\\\\apprise\",\n        \"%PROGRAMFILES%\\\\Apprise\\\\apprise.conf\",\n        \"%PROGRAMFILES%\\\\Apprise\\\\apprise.yml\",\n        \"%PROGRAMFILES%\\\\Apprise\\\\apprise.yaml\",\n        # C:\\Program Files\\Common Files\n        \"%COMMONPROGRAMFILES%\\\\Apprise\\\\apprise\",\n        \"%COMMONPROGRAMFILES%\\\\Apprise\\\\apprise.conf\",\n        \"%COMMONPROGRAMFILES%\\\\Apprise\\\\apprise.yml\",\n        \"%COMMONPROGRAMFILES%\\\\Apprise\\\\apprise.yaml\",\n    )\n\n    # Default Plugin Search Path for Windows Users\n    DEFAULT_PLUGIN_PATHS = (\n        \"%APPDATA%\\\\Apprise\\\\plugins\",\n        \"%LOCALAPPDATA%\\\\Apprise\\\\plugins\",\n        #\n        # Global Support\n        #\n        # C:\\ProgramData\\Apprise\\plugins\n        \"%ALLUSERSPROFILE%\\\\Apprise\\\\plugins\",\n        # C:\\Program Files\\Apprise\\plugins\n        \"%PROGRAMFILES%\\\\Apprise\\\\plugins\",\n        # C:\\Program Files\\Common Files\n        \"%COMMONPROGRAMFILES%\\\\Apprise\\\\plugins\",\n    )\n\n    #\n    # Persistent Storage\n    #\n    DEFAULT_STORAGE_PATH = \"%APPDATA%/Apprise/cache\"\n\n\nclass PersistentStorageMode:\n    \"\"\"Persistent Storage Modes.\"\"\"\n\n    # List all detected configuration loaded\n    LIST = \"list\"\n\n    # Prune persistent storage based on age\n    PRUNE = \"prune\"\n\n    # Reset all (regardless of age)\n    CLEAR = \"clear\"\n\n\n# Define the types in a list for validation purposes\nPERSISTENT_STORAGE_MODES = (\n    PersistentStorageMode.LIST,\n    PersistentStorageMode.PRUNE,\n    PersistentStorageMode.CLEAR,\n)\n\nif os.environ.get(\"APPRISE_STORAGE_PATH\", \"\").strip():\n    # Override Default Storage Path\n    DEFAULT_STORAGE_PATH = os.environ.get(\"APPRISE_STORAGE_PATH\")\n\n\ndef print_version_msg():\n    \"\"\"Prints version message when -V or --version is specified.\"\"\"\n    result = []\n    result.append(f\"{__title__} v{__version__}\")\n    result.append(__copyright__)\n    result.append(f\"This code is licensed under the {__license__} License.\")\n    click.echo(\"\\n\".join(result))\n\n\nclass CustomHelpCommand(click.Command):\n    def format_help(self, ctx, formatter):\n        formatter.write_text(\"Usage:\")\n        formatter.write_text(\n            \"   apprise [OPTIONS] [APPRISE_URL [APPRISE_URL2 [APPRISE_URL3]]]\"\n        )\n        formatter.write_text(\n            \"   apprise storage [OPTIONS] [ACTION] [UID1 [UID2 [UID3]]]\"\n        )\n\n        # Custom help message\n        formatter.write_text(\"\")\n        content = (\n            (\n                \"Send a notification to all of the specified servers \"\n                \"identified by their URLs\"\n            ),\n            (\n                \"the content provided within the title, body and \"\n                \"notification-type.\"\n            ),\n            \"\",\n            (\n                \"For a list of all of the supported services and information\"\n                \" on how to use \"\n            ),\n            \"them, check out https://github.com/caronc/apprise\",\n        )\n\n        for line in content:\n            formatter.write_text(line)\n\n        # Display options and arguments in the default format\n        self.format_options(ctx, formatter)\n        self.format_epilog(ctx, formatter)\n\n        # Custom 'Actions:' section after the 'Options:'\n        formatter.write_text(\"\")\n        formatter.write_text(\"Actions:\")\n\n        actions = [(\n            \"storage\",\n            \"Access the persistent storage disk administration\",\n            [\n                (\n                    \"list\",\n                    (\n                        \"List all URL IDs associated with detected URL(s).\"\n                        \" This is also the default action run if nothing is\"\n                        \" provided\"\n                    ),\n                ),\n                (\n                    \"prune\",\n                    (\n                        \"Eliminates stale entries found based on \"\n                        \"--storage-prune-days (-SPD)\"\n                    ),\n                ),\n                (\n                    \"clean\",\n                    \"Removes any persistent data created by Apprise\",\n                ),\n            ],\n        )]\n\n        #\n        # Some variables\n        #\n\n        # actions are indented this many spaces\n        # sub actions double this value\n        action_indent = 2\n\n        # label padding (for alignment)\n        action_label_width = 10\n\n        space = \" \"\n        space_re = re.compile(r\"\\r*\\n\")\n        cols = 80\n        indent = 10\n\n        # Format each action and its subactions\n        for action, description, sub_actions in actions:\n            # Our action indent\n            ai = \" \" * action_indent\n            # Format the main action description\n            formatted_description = space_re.split(\n                textwrap.fill(\n                    description,\n                    width=(cols - indent - action_indent),\n                    initial_indent=space * indent,\n                    subsequent_indent=space * indent,\n                )\n            )\n            for no, line in enumerate(formatted_description):\n                if not no:\n                    formatter.write_text(\n                        f\"{ai}{action:<{action_label_width}}{line}\"\n                    )\n\n                else:  # pragma: no cover\n                    # Note: no branch is set intentionally since this is not\n                    #       tested since in 2025.08.13 when this was set up\n                    #       it never entered this area of the code.  But we\n                    #       know it works because we repeat this process with\n                    #       our sub-options below\n                    formatter.write_text(\n                        f\"{ai}{space:<{action_label_width}}{line}\"\n                    )\n\n            # Format each subaction\n            ai = \" \" * (action_indent * 2)\n            for action, description in sub_actions:\n                formatted_description = space_re.split(\n                    textwrap.fill(\n                        description,\n                        width=(cols - indent - (action_indent * 3)),\n                        initial_indent=space * (indent - action_indent),\n                        subsequent_indent=space * (indent - action_indent),\n                    )\n                )\n\n                for no, line in enumerate(formatted_description):\n                    if not no:\n                        formatter.write_text(\n                            f\"{ai}{action:<{action_label_width}}{line}\"\n                        )\n                    else:\n                        formatter.write_text(\n                            f\"{ai}{space:<{action_label_width}}{line}\"\n                        )\n\n        # Include any epilog or additional text\n        self.format_epilog(ctx, formatter)\n\n\n@click.command(context_settings=CONTEXT_SETTINGS, cls=CustomHelpCommand)\n@click.option(\n    \"--body\",\n    \"-b\",\n    default=None,\n    type=str,\n    help=(\n        \"Specify the message body. If no body is specified then \"\n        \"content is read from <stdin>.\"\n    ),\n)\n@click.option(\n    \"--title\",\n    \"-t\",\n    default=None,\n    type=str,\n    help=\"Specify the message title. This field is completely optional.\",\n)\n@click.option(\n    \"--plugin-path\",\n    \"-P\",\n    default=None,\n    type=str,\n    multiple=True,\n    metavar=\"PATH\",\n    help=\"Specify one or more plugin paths to scan.\",\n)\n@click.option(\n    \"--storage-path\",\n    \"-S\",\n    default=DEFAULT_STORAGE_PATH,\n    type=str,\n    metavar=\"PATH\",\n    help=(\n        \"Specify the path to the persistent storage location \"\n        f\"(default={DEFAULT_STORAGE_PATH}).\"\n    ),\n)\n@click.option(\n    \"--storage-prune-days\",\n    \"-SPD\",\n    default=DEFAULT_STORAGE_PRUNE_DAYS,\n    type=int,\n    help=(\n        \"Define the number of days the storage prune should run using.\"\n        \" Setting this to zero (0) will eliminate all accumulated content. By\"\n        f\" default this value is {DEFAULT_STORAGE_PRUNE_DAYS} days.\"\n    ),\n)\n@click.option(\n    \"--storage-uid-length\",\n    \"-SUL\",\n    default=DEFAULT_STORAGE_UID_LENGTH,\n    type=int,\n    help=(\n        \"Define the number of unique characters to store persistent cache in.\"\n        f\" By default this value is {DEFAULT_STORAGE_UID_LENGTH} characters.\"\n    ),\n)\n@click.option(\n    \"--storage-mode\",\n    \"-SM\",\n    default=DEFAULT_STORAGE_MODE.value,\n    type=str,\n    metavar=\"MODE\",\n    help=(\n        \"Specify the persistent storage operational mode \"\n        f'(default={DEFAULT_STORAGE_MODE.value}). '\n        'Possible values are: \"{}\".'.format(\n            '\", \"'.join(mode.value for mode in PERSISTENT_STORE_MODE_CHOICES)\n        )\n    ),\n)\n@click.option(\n    \"--config\",\n    \"-c\",\n    default=None,\n    type=str,\n    multiple=True,\n    metavar=\"CONFIG_URL\",\n    help=\"Specify one or more configuration locations.\",\n)\n@click.option(\n    \"--attach\",\n    \"-a\",\n    default=None,\n    type=str,\n    multiple=True,\n    metavar=\"ATTACHMENT_URL\",\n    help=\"Specify one or more attachments.\",\n)\n@click.option(\n    \"--notification-type\",\n    \"-n\",\n    default=DEFAULT_NOTIFY_TYPE.value,\n    type=str,\n    metavar=\"TYPE\",\n    help=(\n        f\"Specify the message type (default={DEFAULT_NOTIFY_TYPE.value}). \"\n        'Possible values are: \"{}\".'.format(\n            '\", \"'.join(nt.value for nt in NOTIFY_TYPE_CHOICES)\n        )\n    ),\n)\n@click.option(\n    \"--input-format\",\n    \"-i\",\n    default=DEFAULT_NOTIFY_FORMAT.value,\n    type=str,\n    metavar=\"FORMAT\",\n    help=(\n        f\"Specify the message input format \"\n        f\"(default={DEFAULT_NOTIFY_FORMAT.value}). \"\n        'Possible values are: \"{}\".'.format(\n            '\", \"'.join(fmt.value for fmt in NOTIFY_FORMAT_CHOICES)\n        )\n    ),\n)\n@click.option(\n    \"--theme\",\n    \"-T\",\n    default=\"default\",\n    type=str,\n    metavar=\"THEME\",\n    help=\"Specify the default theme.\",\n)\n@click.option(\n    \"--tag\",\n    \"-g\",\n    default=None,\n    type=str,\n    multiple=True,\n    metavar=\"TAG\",\n    help=(\n        \"Specify one or more tags to filter which services to notify. Use \"\n        \"multiple --tag (-g) entries to match ANY tag. Use comma separators \"\n        \"to require ALL tags (strict match). Omit to notify untagged services \"\n        'only, or use \"all\" to notify everything.'\n    ),\n)\n@click.option(\n    \"--disable-async\",\n    \"-Da\",\n    is_flag=True,\n    help=\"Send all notifications sequentially\",\n)\n@click.option(\n    \"--dry-run\",\n    \"-d\",\n    is_flag=True,\n    help=(\n        \"Perform a trial run but only prints the notification \"\n        \"services to-be triggered to stdout. Notifications are never \"\n        \"sent using this mode.\"\n    ),\n)\n@click.option(\n    \"--details\",\n    \"-l\",\n    is_flag=True,\n    help=\"Prints details about the current services supported by Apprise.\",\n)\n@click.option(\n    \"--recursion-depth\",\n    \"-R\",\n    default=DEFAULT_RECURSION_DEPTH,\n    type=int,\n    help=(\n        \"The number of recursive import entries that can be \"\n        \"loaded from within Apprise configuration. By default \"\n        f\"this is set to {DEFAULT_RECURSION_DEPTH}.\"\n    ),\n)\n@click.option(\n    \"--verbose\",\n    \"-v\",\n    count=True,\n    help=(\n        \"Makes the operation more talkative. Use multiple v to \"\n        \"increase the verbosity. I.e.: -vvvv\"\n    ),\n)\n@click.option(\n    \"--interpret-escapes\",\n    \"-e\",\n    is_flag=True,\n    help=\"Enable interpretation of backslash escapes\",\n)\n@click.option(\n    \"--interpret-emojis\",\n    \"-j\",\n    is_flag=True,\n    help=\"Enable interpretation of :emoji: definitions\",\n)\n@click.option(\"--debug\", \"-D\", is_flag=True, help=\"Debug mode\")\n@click.option(\n    \"--version\",\n    \"-V\",\n    is_flag=True,\n    help=\"Display the apprise version and exit.\",\n)\n@click.argument(\n    \"urls\",\n    nargs=-1,\n    metavar=\"SERVER_URL [SERVER_URL2 [SERVER_URL3]]\",\n)\n@click.pass_context\ndef main(\n    ctx,\n    body,\n    title,\n    config,\n    attach,\n    urls,\n    notification_type,\n    theme,\n    tag,\n    input_format,\n    dry_run,\n    recursion_depth,\n    verbose,\n    disable_async,\n    details,\n    interpret_escapes,\n    interpret_emojis,\n    plugin_path,\n    storage_path,\n    storage_mode,\n    storage_prune_days,\n    storage_uid_length,\n    debug,\n    version,\n):\n    \"\"\"Send a notification to all of the specified servers identified by their\n    URLs the content provided within the title, body and notification-type.\n\n    For a list of all of the supported services and information on how to use\n    them, check out https://github.com/caronc/apprise\n    \"\"\"\n    # Note: Click ignores the return values of functions it wraps, If you\n    #       want to return a specific error code, you must call ctx.exit()\n    #       as you will see below.\n\n    debug = bool(debug)\n    if debug:\n        # Verbosity must be a minimum of 3\n        verbose = 3 if verbose < 3 else verbose\n\n    # Logging\n    ch = logging.StreamHandler(sys.stdout)\n    if verbose > 3:\n        # -vvvv: Most Verbose Debug Logging\n        logger.setLevel(logging.TRACE)\n\n    elif verbose > 2:\n        # -vvv: Debug Logging\n        logger.setLevel(logging.DEBUG)\n\n    elif verbose > 1:\n        # -vv: INFO Messages\n        logger.setLevel(logging.INFO)\n\n    elif verbose > 0:\n        # -v: WARNING Messages\n        logger.setLevel(logging.WARNING)\n\n    else:\n        # No verbosity means we display ERRORS only AND any deprecation\n        # warnings\n        logger.setLevel(logging.ERROR)\n\n    # Format our logger\n    formatter = logging.Formatter(\"%(asctime)s - %(levelname)s - %(message)s\")\n    ch.setFormatter(formatter)\n    logger.addHandler(ch)\n\n    # Update our asyncio logger\n    asyncio_logger = logging.getLogger(\"asyncio\")\n    for handler in logger.handlers:\n        asyncio_logger.addHandler(handler)\n    asyncio_logger.setLevel(logger.level)\n\n    if version:\n        print_version_msg()\n        ctx.exit(0)\n\n    # Simple Error Checking\n    notification_type = notification_type.strip().lower()\n    if notification_type not in NOTIFY_TYPES:\n        click.echo(\n            f\"The --notification-type (-n) value of {notification_type} is not\"\n            \" supported.\"\n        )\n        click.echo(\"Try 'apprise --help' for more information.\")\n        # 2 is the same exit code returned by Click if there is a parameter\n        # issue.  For consistency, we also return a 2\n        ctx.exit(2)\n\n    input_format = input_format.strip().lower()\n    if input_format not in NOTIFY_FORMATS:\n        click.echo(\n            f\"The --input-format (-i) value of {input_format} is not\"\n            \" supported.\"\n        )\n        click.echo(\"Try 'apprise --help' for more information.\")\n        # 2 is the same exit code returned by Click if there is a parameter\n        # issue.  For consistency, we also return a 2\n        ctx.exit(2)\n\n    storage_mode = storage_mode.strip().lower()\n    if storage_mode not in PERSISTENT_STORE_MODES:\n        click.echo(\n            f\"The --storage-mode (-SM) value of {storage_mode} is not\"\n            \" supported.\"\n        )\n        click.echo(\"Try 'apprise --help' for more information.\")\n        # 2 is the same exit code returned by Click if there is a parameter\n        # issue.  For consistency, we also return a 2\n        ctx.exit(2)\n\n    #\n    # Apply Environment Overrides if defined\n    #\n    config_paths = DEFAULT_CONFIG_PATHS\n    if \"APPRISE_CONFIG\" in os.environ:\n        # Deprecate (this was from previous versions of Apprise <= 1.9.1)\n        logger.deprecate(\n            \"APPRISE_CONFIG environment variable has been changed to \"\n            f\"{DEFAULT_ENV_APPRISE_CONFIG_PATH}\"\n        )\n        logger.debug(\n            \"Loading provided APPRISE_CONFIG (deprecated) environment variable\"\n        )\n        config_paths = (os.environ.get(\"APPRISE_CONFIG\", \"\").strip(),)\n\n    elif DEFAULT_ENV_APPRISE_CONFIG_PATH in os.environ:\n        logger.debug(\n            f\"Loading provided {DEFAULT_ENV_APPRISE_CONFIG_PATH} \"\n            \"environment variable\"\n        )\n        config_paths = re.split(\n            r\"[\\r\\n;]+\",\n            os.environ.get(DEFAULT_ENV_APPRISE_CONFIG_PATH).strip(),\n        )\n\n    plugin_paths_ = DEFAULT_PLUGIN_PATHS\n    if DEFAULT_ENV_APPRISE_PLUGIN_PATH in os.environ:\n        logger.debug(\n            f\"Loading provided {DEFAULT_ENV_APPRISE_PLUGIN_PATH} environment \"\n            \"variable\"\n        )\n        plugin_paths_ = re.split(\n            r\"[\\r\\n;]+\",\n            os.environ.get(DEFAULT_ENV_APPRISE_PLUGIN_PATH).strip(),\n        )\n\n    if DEFAULT_ENV_APPRISE_STORAGE_PATH in os.environ:\n        logger.debug(\n            f\"Loading provided {DEFAULT_ENV_APPRISE_STORAGE_PATH} environment \"\n            \"variable\"\n        )\n        storage_path = os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH).strip()\n\n    #\n    # Continue with initialization process\n    #\n\n    # Prepare a default set of plugin paths to scan; anything specified\n    # on the CLI always trumps\n    plugin_paths = (\n        plugin_path\n        if plugin_path\n        else [path for path in plugin_paths_ if exists(path_decode(path))]\n    )\n\n    if storage_uid_length < 2:\n        click.echo(\n            \"The --storage-uid-length (-SUL) value can not be lower \"\n            \"than two (2).\"\n        )\n        click.echo(\"Try 'apprise --help' for more information.\")\n\n        # 2 is the same exit code returned by Click if there is a\n        # parameter issue.  For consistency, we also return a 2\n        ctx.exit(2)\n\n    # Prepare our asset\n    asset = AppriseAsset(\n        # Our body format\n        body_format=input_format,\n        # Interpret Escapes\n        interpret_escapes=interpret_escapes,\n        # Interpret Emojis\n        interpret_emojis=None if not interpret_emojis else True,\n        # Set the theme\n        theme=theme,\n        # Async mode allows a user to send all of their notifications\n        # asynchronously. This was made an option incase there are problems\n        # in the future where it is better that everything runs sequentially/\n        # synchronously instead.\n        async_mode=disable_async is not True,\n        # Load our plugins\n        plugin_paths=plugin_paths,\n        # Load our persistent storage path\n        storage_path=path_decode(storage_path),\n        # Our storage URL ID Length\n        storage_idlen=storage_uid_length,\n        # Define if we flush to disk as soon as possible or not when required\n        storage_mode=storage_mode,\n    )\n\n    # Create our Apprise object\n    a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL)\n\n    # Track if we are performing a storage action\n    storage_action = bool(urls and \"storage\".startswith(urls[0]))\n\n    if details:\n        # Print details and exit\n        results = a.details(show_requirements=True, show_disabled=True)\n\n        # Sort our results:\n        plugins = sorted(\n            results[\"schemas\"], key=lambda i: str(i[\"service_name\"])\n        )\n        for entry in plugins:\n            protocols = (\n                []\n                if not entry[\"protocols\"]\n                else [p for p in entry[\"protocols\"] if isinstance(p, str)]\n            )\n            protocols.extend(\n                []\n                if not entry[\"secure_protocols\"]\n                else [\n                    p for p in entry[\"secure_protocols\"] if isinstance(p, str)\n                ]\n            )\n\n            if len(protocols) == 1:\n                # Simplify view by swapping {schema} with the single\n                # protocol value\n\n                # Convert tuple to list\n                entry[\"details\"][\"templates\"] = list(\n                    entry[\"details\"][\"templates\"]\n                )\n\n                for x in range(len(entry[\"details\"][\"templates\"])):\n                    entry[\"details\"][\"templates\"][x] = re.sub(\n                        r\"^[^}]+}://\",\n                        f\"{protocols[0]}://\",\n                        entry[\"details\"][\"templates\"][x],\n                    )\n\n            fg = \"green\" if entry[\"enabled\"] else \"red\"\n            if entry[\"category\"] == \"custom\":\n                # Identify these differently\n                fg = \"cyan\"\n                # Flip the enable switch so it forces the requirements\n                # to be displayed\n                entry[\"enabled\"] = False\n\n            click.echo(\n                click.style(\n                    \"{} {:<30} \".format(\n                        \"+\" if entry[\"enabled\"] else \"-\",\n                        str(entry[\"service_name\"]),\n                    ),\n                    fg=fg,\n                    bold=True,\n                ),\n                nl=(not entry[\"enabled\"] or len(protocols) == 1),\n            )\n\n            if not entry[\"enabled\"]:\n                if entry[\"requirements\"][\"details\"]:\n                    click.echo(\"   \" + str(entry[\"requirements\"][\"details\"]))\n\n                if entry[\"requirements\"][\"packages_required\"]:\n                    click.echo(\"   Python Packages Required:\")\n                    for req in entry[\"requirements\"][\"packages_required\"]:\n                        click.echo(\"     - \" + req)\n\n                if entry[\"requirements\"][\"packages_recommended\"]:\n                    click.echo(\"   Python Packages Recommended:\")\n                    for req in entry[\"requirements\"][\"packages_recommended\"]:\n                        click.echo(\"     - \" + req)\n\n                # new line padding between entries\n                if entry[\"category\"] == \"native\":\n                    click.echo()\n                    continue\n\n            if len(protocols) > 1:\n                click.echo(\n                    \"| Schema(s): {}\".format(\n                        \", \".join(protocols),\n                    )\n                )\n\n            prefix = \"   - \"\n            click.echo(\n                \"{}{}\".format(\n                    prefix, f\"\\n{prefix}\".join(entry[\"details\"][\"templates\"])\n                )\n            )\n\n            # new line padding between entries\n            click.echo()\n\n        ctx.exit(0)\n        # end if details()\n\n    # The priorities of what is accepted are parsed in order below:\n    #    1. URLs by command line\n    #    2. Configuration by command line\n    #    3. URLs by environment variable: APPRISE_URLS\n    #    4. Default Configuration File(s)\n    #\n    elif urls and not storage_action:\n        if tag:\n            # Ignore any tags specified\n            logger.warning(\n                \"--tag (-g) entries are ignored when using specified URLs\"\n            )\n            tag = None\n\n        # Load our URLs (if any defined)\n        for url in urls:\n            a.add(url)\n\n        if config:\n            # Provide a warning to the end user if they specified both\n            logger.warning(\n                \"You defined both URLs and a --config (-c) entry; \"\n                \"Only the URLs will be referenced.\"\n            )\n\n    elif config:\n        # We load our configuration file(s) now only if no URLs were specified\n        # Specified config entries trump all\n        a.add(\n            AppriseConfig(paths=config, asset=asset, recursion=recursion_depth)\n        )\n\n    elif os.environ.get(DEFAULT_ENV_APPRISE_URLS, \"\").strip():\n        logger.debug(\n            f\"Loading provided {DEFAULT_ENV_APPRISE_URLS} environment variable\"\n        )\n        if tag:\n            # Ignore any tags specified\n            logger.warning(\n                \"--tag (-g) entries are ignored when using specified URLs\"\n            )\n            tag = None\n\n        # Attempt to use our APPRISE_URLS environment variable (if populated)\n        a.add(os.environ[DEFAULT_ENV_APPRISE_URLS].strip())\n\n    else:\n        # Load default configuration\n        a.add(\n            AppriseConfig(\n                paths=[f for f in config_paths if isfile(path_decode(f))],\n                asset=asset,\n                recursion=recursion_depth,\n            )\n        )\n\n    if not dry_run and not (a or storage_action):\n        click.echo(\n            \"You must specify at least one server URL or populated \"\n            \"configuration file.\"\n        )\n        click.echo(\"Try 'apprise --help' for more information.\")\n        ctx.exit(1)\n\n    # each --tag entry comprises of a comma separated 'and' list\n    # we or each of of the --tag and sets specified.\n    tags = None if not tag else [parse_list(t) for t in tag]\n\n    # Determine if we're dealing with URLs or url_ids based on the first\n    # entry provided.\n    if storage_action:\n        #\n        # Storage Mode\n        #  - urls are now to be interpreted as best matching namespaces\n        #\n        if storage_prune_days < 0:\n            click.echo(\n                \"The --storage-prune-days (-SPD) value can not be lower \"\n                \"than zero (0).\"\n            )\n            click.echo(\"Try 'apprise --help' for more information.\")\n\n            # 2 is the same exit code returned by Click if there is a\n            # parameter issue.  For consistency, we also return a 2\n            ctx.exit(2)\n\n        # Number of columns to assume in the terminal.  In future, maybe this\n        # can be detected and made dynamic. The actual column count is 80, but\n        # 5 characters are already reserved for the counter on the left\n        (columns, _) = shutil.get_terminal_size(fallback=(80, 24))\n\n        # Pop 'storage' off of the head of our list\n        filter_uids = urls[1:]\n\n        action = PERSISTENT_STORAGE_MODES[0]\n        if filter_uids:\n            action_ = next(  # pragma: no branch\n                (\n                    a\n                    for a in PERSISTENT_STORAGE_MODES\n                    if a.startswith(filter_uids[0])\n                ),\n                None,\n            )\n\n            if action_:\n                # pop 'action' off the head of our list\n                filter_uids = filter_uids[1:]\n                action = action_\n\n        # Get our detected URL IDs\n        uids = {}\n        for plugin in a if not tags else a.find(tag=tags):\n            id_ = plugin.url_id()\n            if not id_:\n                continue\n\n            if filter_uids and next(\n                (False for n in filter_uids if id_.startswith(n)), True\n            ):\n                continue\n\n            if id_ not in uids:\n                uids[id_] = {\n                    \"plugins\": [plugin],\n                    \"state\": PersistentStoreState.UNUSED.value,\n                    \"size\": 0,\n                }\n\n            else:\n                # It's possible to have more than one URL point to the same\n                # location (thus match against the same url id more than once\n                uids[id_][\"plugins\"].append(plugin)\n\n        if action == PersistentStorageMode.LIST:\n            detected_uid = PersistentStore.disk_scan(\n                # Use our asset path as it has already been properly parsed\n                path=asset.storage_path,\n                # Provide filter if specified\n                namespace=filter_uids,\n            )\n            for id_ in detected_uid:\n                size, _ = dir_size(os.path.join(asset.storage_path, id_))\n                if id_ in uids:\n                    uids[id_][\"state\"] = PersistentStoreState.ACTIVE.value\n                    uids[id_][\"size\"] = size\n\n                elif not tags:\n                    uids[id_] = {\n                        \"plugins\": [],\n                        # No cross reference (wasted space?)\n                        \"state\": PersistentStoreState.STALE.value,\n                        # Acquire disk space\n                        \"size\": size,\n                    }\n\n            for idx, (uid, meta) in enumerate(uids.items()):\n                fg = (\n                    \"green\"\n                    if meta[\"state\"] == PersistentStoreState.ACTIVE.value\n                    else (\n                        \"red\"\n                        if meta[\"state\"] == PersistentStoreState.STALE.value\n                        else \"white\"\n                    )\n                )\n\n                if idx > 0:\n                    # New line\n                    click.echo()\n                click.echo(f\"{idx + 1: 4d}. \", nl=False)\n                click.echo(\n                    click.style(\n                        \"{:<52} {:<8} {}\".format(\n                            uid, bytes_to_str(meta[\"size\"]), meta[\"state\"]\n                        ),\n                        fg=fg,\n                        bold=True,\n                    )\n                )\n\n                for entry in meta[\"plugins\"]:\n                    url = entry.url(privacy=True)\n                    click.echo(\n                        \"{:>7} {}\".format(\n                            \"-\",\n                            (\n                                url\n                                if len(url) <= (columns - 8)\n                                else f\"{url[:columns - 11]}...\"\n                            ),\n                        )\n                    )\n\n                    if entry.tags:\n                        click.echo(\n                            \"{:>10}: {}\".format(\"tags\", \", \".join(entry.tags))\n                        )\n\n        else:  # PersistentStorageMode.PRUNE or PersistentStorageMode.CLEAR\n            if action == PersistentStorageMode.CLEAR:\n                storage_prune_days = 0\n\n            # clean up storage\n            results = PersistentStore.disk_prune(\n                # Use our asset path as it has already been properly parsed\n                path=asset.storage_path,\n                # Provide our namespaces if they exist\n                namespace=filter_uids if filter_uids else None,\n                # Convert expiry from days to seconds\n                expires=storage_prune_days * 60 * 60 * 24,\n                action=not dry_run,\n            )\n\n            ctx.exit(0)\n            # end if disk_prune()\n\n        ctx.exit(0)\n        # end if storage()\n\n    if not dry_run:\n        if body is None:\n            logger.trace(\"No --body (-b) specified; reading from stdin\")\n            # if no body was specified, then read from STDIN\n            body = click.get_text_stream(\"stdin\").read()\n\n        # now print it out\n        result = a.notify(\n            body=body,\n            title=title,\n            notify_type=notification_type,\n            tag=tags,\n            attach=attach,\n        )\n    else:\n        # Number of columns to assume in the terminal.  In future, maybe this\n        # can be detected and made dynamic. The actual column count is 80, but\n        # 5 characters are already reserved for the counter on the left\n        (columns, _) = shutil.get_terminal_size(fallback=(80, 24))\n\n        # Initialize our URL response;  This is populated within the for/loop\n        # below; but plays a factor at the end when we need to determine if\n        # we iterated at least once in the loop.\n        url = None\n\n        for idx, server in enumerate(a.find(tag=tags)):\n            url = server.url(privacy=True)\n            click.echo(\n                \"{: 4d}. {}\".format(\n                    idx + 1,\n                    (\n                        url\n                        if len(url) <= (columns - 8)\n                        else f\"{url[:columns - 9]}...\"\n                    ),\n                )\n            )\n\n            # Share our URL ID\n            click.echo(\n                \"{:>10}: {}\".format(\n                    \"uid\",\n                    \"- n/a -\" if not server.url_id() else server.url_id(),\n                )\n            )\n\n            if server.tags:\n                click.echo(\"{:>10}: {}\".format(\"tags\", \", \".join(server.tags)))\n\n        # Initialize a default response of nothing matched, otherwise\n        # if we matched at least one entry, we can return True\n        result = None if url is None else True\n\n    if result is None:\n        # There were no notifications set.  This is a result of just having\n        # empty configuration files and/or being to restrictive when filtering\n        # by specific tag(s)\n\n        # Exit code 3 is used since Click uses exit code 2 if there is an\n        # error with the parameters specified\n        ctx.exit(3)\n\n    elif result is False:\n        # At least 1 notification service failed to send\n        ctx.exit(1)\n\n    # else:  We're good!\n    ctx.exit(0)\n"
  },
  {
    "path": "apprise/common.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom enum import Enum\n\n# isoformat is spelled out for compatibility with Python v3.6\nAWARE_DATE_ISO_FORMAT = \"%Y-%m-%dT%H:%M:%S.%f%z\"\nNAIVE_DATE_ISO_FORMAT = \"%Y-%m-%dT%H:%M:%S.%f\"\n\n\nclass NotifyType(str, Enum):\n    \"\"\"A simple mapping of notification types most commonly used with all types\n    of logging and notification services.\"\"\"\n\n    INFO = \"info\"\n    SUCCESS = \"success\"\n    WARNING = \"warning\"\n    FAILURE = \"failure\"\n\n\n# Define our types so we can verify if we need to\nNOTIFY_TYPES: frozenset[str] = frozenset(e.value for e in NotifyType)\n\n\nclass NotifyImageSize(str, Enum):\n    \"\"\"A list of pre-defined image sizes to make it easier to work with defined\n    plugins.\"\"\"\n\n    XY_32 = \"32x32\"\n    XY_72 = \"72x72\"\n    XY_128 = \"128x128\"\n    XY_256 = \"256x256\"\n\n\n# Define our image sizes so we can verify if we need to\nNOTIFY_IMAGE_SIZES: frozenset[str] = \\\n    frozenset(e.value for e in NotifyImageSize)\n\n\nclass NotifyFormat(str, Enum):\n    \"\"\"A list of pre-defined text message formats that can be passed via the\n    apprise library.\"\"\"\n\n    TEXT = \"text\"\n    HTML = \"html\"\n    MARKDOWN = \"markdown\"\n\n\n# Define our formats so we can verify if we need to\nNOTIFY_FORMATS: frozenset[str] = frozenset(e.value for e in NotifyFormat)\n\n\nclass OverflowMode(str, Enum):\n    \"\"\"A list of pre-defined modes of how to handle the text when it exceeds\n    the defined maximum message size.\"\"\"\n\n    # Send the data as is; untouched.  Let the upstream server decide how the\n    # content is handled.  Some upstream services might gracefully handle this\n    # with expected intentions; others might not.\n    UPSTREAM = \"upstream\"\n\n    # Always truncate the text when it exceeds the maximum message size and\n    # send it anyway\n    TRUNCATE = \"truncate\"\n\n    # Split the message into multiple smaller messages that fit within the\n    # limits of what is expected.  The smaller messages are sent\n    SPLIT = \"split\"\n\n\n# Define our modes so we can verify if we need to\nOVERFLOW_MODES: frozenset[str] = frozenset(e.value for e in OverflowMode)\n\n\nclass ConfigFormat(str, Enum):\n    \"\"\"A list of pre-defined config formats that can be passed via the apprise\n    library.\"\"\"\n\n    # A text based configuration. This consists of a list of URLs delimited by\n    # a new line.  pound/hashtag (#) or semi-colon (;) can be used as comment\n    # characters.\n    TEXT = \"text\"\n\n    # YAML files allow a more rich of an experience when settig up your\n    # apprise configuration files.\n    YAML = \"yaml\"\n\n\n# Define our configuration formats mostly used for verification\nCONFIG_FORMATS: frozenset[str] = frozenset(e.value for e in ConfigFormat)\n\n\nclass ContentIncludeMode(str, Enum):\n    \"\"\"The different Content inclusion modes.\n\n    All content based plugins will have one of these associated with it.\n    \"\"\"\n\n    # - Content inclusion of same type only; hence a file:// can include\n    #   a file://\n    # - Cross file inclusion is not allowed unless insecure_includes (a flag)\n    #   is set to True. In these cases STRICT acts as type ALWAYS\n    STRICT = \"strict\"\n\n    # This content type can never be included\n    NEVER = \"never\"\n\n    # This content can always be included\n    ALWAYS = \"always\"\n\n\n# Define our file inclusion types so we can verify if we need to\nCONTENT_INCLUDE_MODES: frozenset[str] = \\\n    frozenset(e.value for e in ContentIncludeMode)\n\n\nclass ContentLocation(str, Enum):\n    \"\"\"This is primarily used for handling file attachments.  The idea is to\n    track the source of the attachment itself.  We don't want remote calls to a\n    server to access local attachments for example.\n\n    By knowing the attachment type and cross-associating it with how we plan on\n    accessing the content, we can make a judgement call (for security reasons)\n    if we will allow it.\n\n    Obviously local uses of apprise can access both local and remote type\n    files.\n    \"\"\"\n\n    # Content is located locally (on the same server as apprise)\n    LOCAL = \"local\"\n\n    # Content is located in a remote location\n    HOSTED = \"hosted\"\n\n    # Content is inaccessible\n    INACCESSIBLE = \"n/a\"\n\n\n# Define our location types so we can verify if we need to\nCONTENT_LOCATIONS: frozenset[str] = frozenset(e.value for e in ContentLocation)\n\n\nclass PersistentStoreMode(str, Enum):\n    # Allow persistent storage; write on demand\n    AUTO = \"auto\"\n\n    # Always flush every change to disk after it's saved. This has higher i/o\n    # but enforces disk reflects what was set immediately\n    FLUSH = \"flush\"\n\n    # memory based store only\n    MEMORY = \"memory\"\n\n\n# Define our persistent storage modes so we can verify if we need to\nPERSISTENT_STORE_MODES: frozenset[str] = \\\n    frozenset(e.value for e in PersistentStoreMode)\n\n\nclass PersistentStoreState(str, Enum):\n    \"\"\"Defines the persistent states describing what has been cached.\"\"\"\n\n    # Persistent Directory is actively cross-referenced against a matching URL\n    ACTIVE = \"active\"\n\n    # Persistent Directory is no longer being used or has no cross-reference\n    STALE = \"stale\"\n\n    # Persistent Directory is not utilizing any disk space at all, however\n    # it potentially could if the plugin it successfully cross-references\n    # is utilized\n    UNUSED = \"unused\"\n\n\n# Define our persistent storage states so we can verify if we need to\nPERSISTENT_STORE_STATES: frozenset[str] = \\\n    frozenset(e.value for e in PersistentStoreState)\n\n# This is a reserved tag that is automatically assigned to every\n# Notification Plugin\nMATCH_ALL_TAG = \"all\"\n\n# Will cause notification to trigger under any circumstance even if an\n# exclusive tagging was provided.\nMATCH_ALWAYS_TAG = \"always\"\n"
  },
  {
    "path": "apprise/compat.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Added for Python 3.9 Compatibility\n\nfrom dataclasses import dataclass as _dataclass\nfrom typing import Any, Callable, TypeVar\n\n_T = TypeVar(\"_T\")\n\n\ndef dataclass_compat(*dargs: Any, **dkwargs: Any) -> Callable[[_T], _T]:\n    \"\"\"\n    dataclass() wrapper that drops unsupported kwargs on older Python.\n\n    Python 3.9 does not support slots= in dataclasses.dataclass().\n    \"\"\"\n    try:\n        return _dataclass(*dargs, **dkwargs)\n\n    except TypeError:\n        # Only strip slots when it is the cause\n        if \"slots\" in dkwargs:\n            dkwargs.pop(\"slots\", None)\n            return _dataclass(*dargs, **dkwargs)\n        raise\n"
  },
  {
    "path": "apprise/config/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Used for testing\nfrom ..manager_config import ConfigurationManager\nfrom .base import ConfigBase\n\n# Initalize our Config Manager Singleton\nC_MGR = ConfigurationManager()\n\n__all__ = [\n    # Reference\n    \"ConfigBase\",\n    \"ConfigurationManager\",\n]\n"
  },
  {
    "path": "apprise/config/base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nfrom __future__ import annotations\n\nfrom collections import deque\nimport os\nimport re\nimport time\n\nimport yaml\n\nfrom .. import common, plugins\nfrom ..asset import AppriseAsset\nfrom ..logger import logging\nfrom ..manager_config import ConfigurationManager\nfrom ..manager_plugins import NotificationManager\nfrom ..url import URLBase\nfrom ..utils.cwe312 import cwe312_url\nfrom ..utils.parse import GET_SCHEMA_RE, parse_bool, parse_list, parse_urls\nfrom ..utils.time import zoneinfo\n\n# Test whether token is valid or not\nVALID_TOKEN = re.compile(r\"(?P<token>[a-z0-9][a-z0-9_]+)\", re.I)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n# Grant access to our Configuration Manager Singleton\nC_MGR = ConfigurationManager()\n\n\nclass ConfigBase(URLBase):\n    \"\"\"This is the base class for all supported configuration sources.\"\"\"\n\n    # The Default Encoding to use if not otherwise detected\n    encoding = \"utf-8\"\n\n    # The default expected configuration format unless otherwise\n    # detected by the sub-modules\n    default_config_format = common.ConfigFormat.TEXT\n\n    # This is only set if the user overrides the config format on the URL\n    # this should always initialize itself as None\n    config_format = None\n\n    # Don't read any more of this amount of data into memory as there is no\n    # reason we should be reading in more. This is more of a safe guard then\n    # anything else. 128KB (131072B)\n    max_buffer_size = 131072\n\n    # By default all configuration is not includable using the 'include'\n    # line found in configuration files.\n    allow_cross_includes = common.ContentIncludeMode.NEVER\n\n    # the config path manages the handling of relative include\n    config_path = os.getcwd()\n\n    def __init__(\n        self,\n        cache: bool | int = True,\n        recursion: int = 0,\n        insecure_includes: bool = False,\n        **kwargs: object,\n    ) -> None:\n        \"\"\"Initialize some general logging and common server arguments that\n        will keep things consistent when working with the configurations that\n        inherit this class.\n\n        By default we cache our responses so that subsiquent calls does not\n        cause the content to be retrieved again.  For local file references\n        this makes no difference at all.  But for remote content, this does\n        mean more then one call can be made to retrieve the (same) data.  This\n        method can be somewhat inefficient if disabled.  Only disable caching\n        if you understand the consequences.\n\n        You can alternatively set the cache value to an int identifying the\n        number of seconds the previously retrieved can exist for before it\n        should be considered expired.\n\n        recursion defines how deep we recursively handle entries that use the\n        `include` keyword. This keyword requires us to fetch more configuration\n        from another source and add it to our existing compilation. If the\n        file we remotely retrieve also has an `include` reference, we will only\n        advance through it if recursion is set to 2 deep.  If set to zero\n        it is off.  There is no limit to how high you set this value. It would\n        be recommended to keep it low if you do intend to use it.\n\n        insecure_include by default are disabled. When set to True, all\n        Apprise Config files marked to be in STRICT mode are treated as being\n        in ALWAYS mode.\n\n        Take a file:// based configuration for example, only a file:// based\n        configuration can include another file:// based one. because it is set\n        to STRICT mode. If an http:// based configuration file attempted to\n        include a file:// one it woul fail. However this include would be\n        possible if insecure_includes is set to True.\n\n        There are cases where a self hosting apprise developer may wish to load\n        configuration from memory (in a string format) that contains 'include'\n        entries (even file:// based ones).  In these circumstances if you want\n        these 'include' entries to be honored, this value must be set to True.\n        \"\"\"\n\n        super().__init__(**kwargs)\n\n        # Tracks the time the content was last retrieved on.  This place a role\n        # for cases where we are not caching our response and are required to\n        # re-retrieve our settings.\n        self._cached_time = None\n\n        # Tracks previously loaded content for speed\n        self._cached_servers = None\n\n        # Initialize our recursion value\n        self.recursion = recursion\n\n        # Initialize our insecure_includes flag\n        self.insecure_includes = insecure_includes\n\n        if \"encoding\" in kwargs:\n            # Store the encoding\n            self.encoding = kwargs.get(\"encoding\")\n\n        fmt = kwargs.get(\"format\")\n        if fmt:\n            try:\n                self.config_format = (\n                    fmt if isinstance(fmt, common.ConfigFormat)\n                    else common.ConfigFormat(fmt.lower())\n                )\n\n            except (AttributeError, ValueError):\n                err = f\"An invalid config format ({fmt}) was specified.\"\n                self.logger.warning(err)\n                raise TypeError(err) from None\n\n        # Set our cache flag; it can be True or a (positive) integer\n        try:\n            self.cache = cache if isinstance(cache, bool) else int(cache)\n            if self.cache < 0:\n                err = f\"A negative cache value ({cache}) was specified.\"\n                self.logger.warning(err)\n                raise TypeError(err)\n\n        except (ValueError, TypeError):\n            err = f\"An invalid cache value ({cache}) was specified.\"\n            self.logger.warning(err)\n            raise TypeError(err) from None\n\n        return\n\n    def servers(\n        self,\n        asset: AppriseAsset | None = None,\n        **kwargs: object,\n    ) -> list[plugins.NotifyBase]:\n        \"\"\"Performs reads loaded configuration and returns all of the services\n        that could be parsed and loaded.\"\"\"\n\n        if not self.expired():\n            # We already have cached results to return; use them\n            return self._cached_servers\n\n        # Our cached response object\n        self._cached_servers = []\n\n        # read() causes the child class to do whatever it takes for the\n        # config plugin to load the data source and return unparsed content\n        # None is returned if there was an error or simply no data\n        content = self.read(**kwargs)\n        if not isinstance(content, str):\n            # Set the time our content was cached at\n            self._cached_time = time.time()\n\n            # Nothing more to do; return our empty cache list\n            return self._cached_servers\n\n        # Our Configuration format uses a default if one wasn't one detected\n        # or enfored.\n        config_format = (\n            self.default_config_format\n            if self.config_format is None\n            else self.config_format\n        )\n\n        # Dynamically load our parse_ function based on our config format\n        fn = getattr(ConfigBase, f\"config_parse_{config_format.value}\")\n\n        # Initialize our asset object\n        asset = asset if isinstance(asset, AppriseAsset) else self.asset\n\n        # Execute our config parse function which always returns a tuple\n        # of our servers and our configuration\n        servers, configs = fn(content=content, asset=asset)\n\n        # Free memory\n        del content\n\n        # Add entry to our server list\n        self._cached_servers.extend(servers)\n\n        # Configuration files were detected; recursively populate them\n        # If we have been configured to do so\n        for url in configs:\n\n            if self.recursion > 0:\n                # Attempt to acquire the schema at the very least to allow\n                # our configuration based urls.\n                schema = GET_SCHEMA_RE.match(url)\n                if schema is None:\n                    # Plan B is to assume we're dealing with a file\n                    schema = \"file\"\n                    if not os.path.isabs(url):\n                        # We're dealing with a relative path; prepend\n                        # our current config path\n                        url = os.path.join(self.config_path, url)\n\n                    url = f\"{schema}://{URLBase.quote(url)}\"\n\n                else:\n                    # Ensure our schema is always in lower case\n                    schema = schema.group(\"schema\").lower()\n\n                    # Some basic validation\n                    if schema not in C_MGR:\n                        ConfigBase.logger.warning(\n                            f\"Unsupported include schema {schema}.\"\n                        )\n                        continue\n\n                # CWE-312 (Secure Logging) Handling\n                loggable_url = (\n                    url if not asset.secure_logging else cwe312_url(url)\n                )\n\n                # Parse our url details of the server object as dictionary\n                # containing all of the information parsed from our URL\n                results = C_MGR[schema].parse_url(url)\n                if not results:\n                    # Failed to parse the server URL\n                    self.logger.warning(\n                        f\"Unparseable include URL {loggable_url}\"\n                    )\n                    continue\n\n                # Handle cross inclusion based on allow_cross_includes rules\n                if (\n                    C_MGR[schema].allow_cross_includes\n                    == common.ContentIncludeMode.STRICT\n                    and schema not in self.schemas()\n                    and not self.insecure_includes\n                ) or C_MGR[\n                    schema\n                ].allow_cross_includes == common.ContentIncludeMode.NEVER:\n\n                    # Prevent the loading if insecure base protocols\n                    ConfigBase.logger.warning(\n                        f\"Including {schema}:// based configuration is\"\n                        f\" prohibited. Ignoring URL {loggable_url}\"\n                    )\n                    continue\n\n                # Prepare our Asset Object\n                results[\"asset\"] = asset\n\n                # No cache is required because we're just lumping this in\n                # and associating it with the cache value we've already\n                # declared (prior to our recursion)\n                results[\"cache\"] = False\n\n                # Recursion can never be parsed from the URL; we decrement\n                # it one level\n                results[\"recursion\"] = self.recursion - 1\n\n                # Insecure Includes flag can never be parsed from the URL\n                results[\"insecure_includes\"] = self.insecure_includes\n\n                try:\n                    # Attempt to create an instance of our plugin using the\n                    # parsed URL information\n                    cfg_plugin = C_MGR[results[\"schema\"]](**results)\n\n                except Exception as e:\n                    # the arguments are invalid or can not be used.\n                    self.logger.warning(\n                        f\"Could not load include URL: {loggable_url}\"\n                    )\n                    self.logger.debug(f\"Loading Exception: {e!s}\")\n                    continue\n\n                # if we reach here, we can now add this servers found\n                # in this configuration file to our list\n                self._cached_servers.extend(cfg_plugin.servers(asset=asset))\n\n            else:\n                # CWE-312 (Secure Logging) Handling\n                loggable_url = (\n                    url if not asset.secure_logging else cwe312_url(url)\n                )\n\n                self.logger.debug(\n                    \"Recursion limit reached; ignoring Include URL: %s\",\n                    loggable_url,\n                )\n\n        if self._cached_servers:\n            self.logger.info(\n                f\"Loaded {len(self._cached_servers)} entries from\"\n                f\" {self.url(privacy=asset.secure_logging)}\"\n            )\n        else:\n            self.logger.warning(\n                \"Failed to load Apprise configuration from\"\n                f\" {self.url(privacy=asset.secure_logging)}\"\n            )\n\n        # Set the time our content was cached at\n        self._cached_time = time.time()\n\n        return self._cached_servers\n\n    def read(self) -> str | None:\n        \"\"\"This object should be implimented by the child classes.\"\"\"\n        return None\n\n    def expired(self) -> bool:\n        \"\"\"Simply returns True if the configuration should be considered as\n        expired or False if content should be retrieved.\"\"\"\n        if isinstance(self._cached_servers, list) and self.cache:\n            # We have enough reason to look further into our cached content\n            # and verify it has not expired.\n            if self.cache is True:\n                # we have not expired, return False\n                return False\n\n            # Verify our cache time to determine whether we will get our\n            # content again.\n            age_in_sec = time.time() - self._cached_time\n            if age_in_sec <= self.cache:\n                # We have not expired; return False\n                return False\n\n        # If we reach here our configuration should be considered\n        # missing and/or expired.\n        return True\n\n    @staticmethod\n    def __normalize_tag_groups(group_tags: dict[str, set[str]]) -> None:\n        \"\"\"\n        Used to normalize a tag assign map which looks like:\n          {\n             'group': set('{tag1}', '{group1}', '{tag2}'),\n             'group1': set('{tag2}','{tag3}'),\n          }\n\n          Then normalized it (merging groups); with respect to the above, the\n          output would be:\n          {\n             'group': set('{tag1}', '{tag2}', '{tag3}),\n             'group1': set('{tag2}','{tag3}'),\n          }\n\n        \"\"\"\n        # Prepare a key set list we can use\n        tag_groups = {str(x) for x in group_tags}\n\n        def _expand(tags, ignore=None):\n            \"\"\"Expands based on tag provided and returns a set.\n\n            this also updates the group_tags while it goes\n            \"\"\"\n\n            # Prepare ourselves a return set\n            results = set()\n            ignore = set() if ignore is None else ignore\n\n            # track groups\n            groups = set()\n\n            for tag in tags:\n                if tag in ignore:\n                    continue\n\n                # Track our groups\n                groups.add(tag)\n\n                # Store what we know is worth keeping\n                if tag not in group_tags:  # pragma: no cover\n                    # handle cases where the tag doesn't exist\n                    group_tags[tag] = set()\n\n                results |= group_tags[tag] - tag_groups\n\n                # Get simple tag assignments\n                found = group_tags[tag] & tag_groups\n                if not found:\n                    continue\n\n                for gtag in found:\n                    if gtag in ignore:\n                        continue\n\n                    # Go deeper (recursion)\n                    ignore.add(tag)\n                    group_tags[gtag] = _expand({gtag}, ignore=ignore)\n                    results |= group_tags[gtag]\n\n                    # Pop ignore\n                    ignore.remove(tag)\n\n            return results\n\n        for tag in tag_groups:\n            # Get our tags\n            group_tags[tag] |= _expand({tag})\n            if not group_tags[tag]:\n                ConfigBase.logger.warning(\n                    f\"The group {tag} has no tags assigned to it\"\n                )\n                del group_tags[tag]\n\n    @staticmethod\n    def parse_url(\n        url: str,\n        verify_host: bool = True,\n    ) -> dict[str, object] | None:\n        \"\"\"Parses the URL and returns it broken apart into a dictionary.\n\n        This is very specific and customized for Apprise.\n\n        Args:\n            url (str): The URL you want to fully parse.\n            verify_host (:obj:`bool`, optional): a flag kept with the parsed\n                 URL which some child classes will later use to verify SSL\n                 keys (if SSL transactions take place).  Unless under very\n                 specific circumstances, it is strongly recomended that\n                 you leave this default value set to True.\n\n        Returns:\n            A dictionary is returned containing the URL fully parsed if\n            successful, otherwise None is returned.\n        \"\"\"\n\n        results = URLBase.parse_url(url, verify_host=verify_host)\n\n        if not results:\n            # We're done; we failed to parse our url\n            return results\n\n        # Allow overriding the default config format\n        if \"format\" in results[\"qsd\"]:\n            results[\"format\"] = results[\"qsd\"].get(\"format\")\n            if results[\"format\"] not in common.CONFIG_FORMATS:\n                URLBase.logger.warning(\n                    \"Unsupported format specified {}\".format(results[\"format\"])\n                )\n                del results[\"format\"]\n\n        # Defines the encoding of the payload\n        if \"encoding\" in results[\"qsd\"]:\n            results[\"encoding\"] = results[\"qsd\"].get(\"encoding\")\n\n        # Our cache value\n        if \"cache\" in results[\"qsd\"]:\n            # First try to get it's integer value\n            try:\n                results[\"cache\"] = int(results[\"qsd\"][\"cache\"])\n\n            except (ValueError, TypeError):\n                # No problem, it just isn't an integer; now treat it as a bool\n                # instead:\n                results[\"cache\"] = parse_bool(results[\"qsd\"][\"cache\"])\n\n        return results\n\n    @staticmethod\n    def detect_config_format(\n        content: str,\n        **kwargs: object,\n    ) -> common.ConfigFormat | None:\n        \"\"\"Takes the specified content and attempts to detect the format type.\n\n        The function returns the actual format type if detected, otherwise it\n        returns None\n        \"\"\"\n\n        # Detect Format Logic:\n        #  - A pound/hashtag (#) is alawys a comment character so we skip over\n        #     lines matched here.\n        #  - Detection begins on the first non-comment and non blank line\n        #     matched.\n        #  - If we find a string followed by a colon, we know we're dealing\n        #     with a YAML file.\n        #  - If we find a string that starts with a URL, or our tag\n        #     definitions (accepting commas) followed by an equal sign we know\n        #     we're dealing with a TEXT format.\n\n        # Define what a valid line should look like\n        valid_line_re = re.compile(\n            r\"^\\s*(?P<line>([;#]+(?P<comment>.*))|\"\n            r\"(?P<text>((?P<tag>[ \\t,a-z0-9_-]+)=)?[a-z0-9]+://.*)|\"\n            r\"((?P<yaml>[a-z0-9]+):.*))?$\",\n            re.I,\n        )\n\n        try:\n            # split our content up to read line by line\n            content = re.split(r\"\\r*\\n\", content)\n\n        except TypeError:\n            # content was not expected string type\n            ConfigBase.logger.error(\"Invalid Apprise configuration specified.\")\n            return None\n\n        # By default set our return value to None since we don't know\n        # what the format is yet\n        config_format = None\n\n        # iterate over each line of the file to attempt to detect it\n        # stop the moment a the type has been determined\n        for line, entry in enumerate(content, start=1):\n\n            result = valid_line_re.match(entry)\n            if not result:\n                # Invalid syntax\n                ConfigBase.logger.error(\n                    \"Undetectable Apprise configuration found \"\n                    f\"based on line {line}.\"\n                )\n                # Take an early exit\n                return None\n\n            # Attempt to detect configuration\n            if result.group(\"yaml\"):\n                config_format = common.ConfigFormat.YAML\n                ConfigBase.logger.debug(\n                    f\"Detected YAML configuration based on line {line}.\"\n                )\n                break\n\n            elif result.group(\"text\"):\n                config_format = common.ConfigFormat.TEXT\n                ConfigBase.logger.debug(\n                    f\"Detected TEXT configuration based on line {line}.\"\n                )\n                break\n\n            # If we reach here, we have a comment entry\n            # Adjust default format to TEXT\n            config_format = common.ConfigFormat.TEXT\n\n        return config_format\n\n    @staticmethod\n    def config_parse(\n        content: str,\n        asset: AppriseAsset | None = None,\n        config_format: str | common.ConfigFormat | None = None,\n        **kwargs: object,\n    ) -> tuple[list[object], list[str]]:\n        \"\"\"Takes the specified config content and loads it based on the\n        specified config_format.\n\n        If a format isn't specified, then it is auto detected.\n        \"\"\"\n\n        if config_format is None:\n            # Detect the format\n            config_format = ConfigBase.detect_config_format(content)\n\n            if not config_format:\n                # We couldn't detect configuration\n                ConfigBase.logger.error(\"Could not detect configuration\")\n                return ([], [])\n\n        try:\n            config_format = (\n                config_format if isinstance(config_format, common.ConfigFormat)\n                else common.ConfigFormat(config_format.lower())\n            )\n\n        except (AttributeError, ValueError):\n            ConfigBase.logger.error(\n                f\"An invalid configuration format ({config_format}) was\"\n                \" specified\"\n            )\n            return ([], [])\n\n        # Dynamically load our parse_ function based on our config format\n        fn = getattr(ConfigBase, f\"config_parse_{config_format.value}\")\n\n        # Execute our config parse function which always returns a list\n        return fn(content=content, asset=asset)\n\n    @staticmethod\n    def config_parse_text(\n        content: str,\n        asset: AppriseAsset | None = None,\n    ) -> tuple[list[object], list[str]]:\n        \"\"\"Parse the specified content as though it were a simple text file\n        only containing a list of URLs.\n\n        Return a tuple that looks like (servers, configs) where:\n          - servers contains a list of loaded notification plugins\n          - configs contains a list of additional configuration files\n            referenced.\n\n        You may also optionally associate an asset with the notification.\n\n        The file syntax is:\n\n            #\n            # pound/hashtag allow for line comments\n            #\n            # One or more tags can be idenified using comma's (,) to separate\n            # them.\n            <Tag(s)>=<URL>\n\n            # Or you can use this format (no tags associated)\n            <URL>\n\n            # you can also use the keyword 'include' and identify a\n            # configuration location (like this file) which will be included\n            # as additional configuration entries when loaded.\n            include <ConfigURL>\n\n            # Assign tag contents to a group identifier\n            <Group(s)>=<Tag(s)>\n        \"\"\"\n        # A list of loaded Notification Services\n        servers = []\n\n        # A list of additional configuration files referenced using\n        # the include keyword\n        configs = []\n\n        # Track all of the tags we want to assign later on\n        group_tags = {}\n\n        # Track our entries to preload\n        preloaded = []\n\n        # Prepare our Asset Object\n        asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n\n        # Define what a valid line should look like\n        valid_line_re = re.compile(\n            r\"^\\s*(?P<line>([;#]+(?P<comment>.*))|\"\n            r\"(\\s*(?P<tags>[a-z0-9, \\t_-]+)\\s*=|=)?\\s*\"\n            r\"((?P<url>[a-z0-9]{1,32}://.*)|(?P<assign>[a-z0-9, \\t_-]+))|\"\n            r\"include\\s+(?P<config>.+))?\\s*$\",\n            re.I,\n        )\n\n        try:\n            # split our content up to read line by line\n            content = re.split(r\"\\r*\\n\", content)\n\n        except TypeError:\n            # content was not expected string type\n            ConfigBase.logger.error(\n                \"Invalid Apprise TEXT based configuration specified.\"\n            )\n            return ([], [])\n\n        for line, entry in enumerate(content, start=1):\n            result = valid_line_re.match(entry)\n            if not result:\n                # Invalid syntax\n                ConfigBase.logger.error(\n                    \"Invalid Apprise TEXT configuration format found \"\n                    f\"{entry} on line {line}.\"\n                )\n\n                # Assume this is a file we shouldn't be parsing. It's owner\n                # can read the error printed to screen and take action\n                # otherwise.\n                return ([], [])\n\n            # Retrieve our line\n            url, assign, config = (\n                result.group(\"url\"),\n                result.group(\"assign\"),\n                result.group(\"config\"),\n            )\n\n            if not (url or config or assign):\n                # Comment/empty line; do nothing\n                continue\n\n            if config:\n                # CWE-312 (Secure Logging) Handling\n                loggable_url = (\n                    config if not asset.secure_logging else cwe312_url(config)\n                )\n\n                ConfigBase.logger.debug(f\"Include URL: {loggable_url}\")\n\n                # Store our include line\n                configs.append(config.strip())\n                continue\n\n            # CWE-312 (Secure Logging) Handling\n            loggable_url = url if not asset.secure_logging else cwe312_url(url)\n\n            if assign:\n                groups = set(parse_list(result.group(\"tags\"), cast=str))\n                if not groups:\n                    # no tags were assigned\n                    ConfigBase.logger.warning(\n                        \"Unparseable tag assignment - no group(s) \"\n                        f\"on line {line}\"\n                    )\n                    continue\n\n                # Get our tags\n                tags = set(parse_list(assign, cast=str))\n                if not tags:\n                    # no tags were assigned\n                    ConfigBase.logger.warning(\n                        \"Unparseable tag assignment - no tag(s) to assign \"\n                        f\"on line {line}\"\n                    )\n                    continue\n\n                # Update our tag group map\n                for tag_group in groups:\n                    if tag_group not in group_tags:\n                        group_tags[tag_group] = set()\n\n                    # ensure our tag group is never included in the assignment\n                    group_tags[tag_group] |= tags - {tag_group}\n                continue\n\n            # Acquire our url tokens\n            results = plugins.url_to_dict(\n                url, secure_logging=asset.secure_logging\n            )\n            if results is None:\n                # Failed to parse the server URL\n                ConfigBase.logger.warning(\n                    f\"Unparseable URL {loggable_url} on line {line}.\"\n                )\n                continue\n\n            # Build a list of tags to associate with the newly added\n            # notifications if any were set\n            results[\"tag\"] = set(parse_list(result.group(\"tags\"), cast=str))\n\n            # Set our Asset Object\n            results[\"asset\"] = asset\n\n            # Store our preloaded entries\n            preloaded.append({\n                \"results\": results,\n                \"line\": line,\n                \"loggable_url\": loggable_url,\n            })\n\n        #\n        # Normalize Tag Groups\n        # - Expand Groups of Groups so that they don't exist\n        #\n        ConfigBase.__normalize_tag_groups(group_tags)\n\n        #\n        # URL Processing\n        #\n        for entry in preloaded:\n            # Point to our results entry for easier reference below\n            results = entry[\"results\"]\n\n            #\n            # Apply our tag groups if they're defined\n            #\n            for group, tags in group_tags.items():\n                # Detect if anything assigned to this tag also maps back to a\n                # group.  If so we want to add the group to our list\n                if next(\n                    (True for tag in results[\"tag\"] if tag in tags), False\n                ):\n                    results[\"tag\"].add(group)\n\n            try:\n                # Attempt to create an instance of our plugin using the\n                # parsed URL information\n                plugin = N_MGR[results[\"schema\"]](**results)\n\n                # Create log entry of loaded URL\n                ConfigBase.logger.debug(\n                    \"Loaded URL: %s\",\n                    plugin.url(privacy=results[\"asset\"].secure_logging),\n                )\n\n            except Exception as e:\n                # the arguments are invalid or can not be used.\n                ConfigBase.logger.warning(\n                    \"Could not load URL {} on line {}.\".format(\n                        entry[\"loggable_url\"], entry[\"line\"]\n                    )\n                )\n                ConfigBase.logger.debug(f\"Loading Exception: {e!s}\")\n                continue\n\n            # if we reach here, we successfully loaded our data\n            servers.append(plugin)\n\n        # Return what was loaded\n        return (servers, configs)\n\n    @staticmethod\n    def config_parse_yaml(\n        content: str,\n        asset: AppriseAsset | None = None,\n    ) -> tuple[list[object], list[str]]:\n        \"\"\"Parse the specified content as though it were a yaml file\n        specifically formatted for Apprise.\n\n        Return a tuple that looks like (servers, configs) where:\n          - servers contains a list of loaded notification plugins\n          - configs contains a list of additional configuration files\n            referenced.\n\n        You may optionally associate an asset with the notification.\n        \"\"\"\n\n        # A list of loaded Notification Services\n        servers = []\n\n        # A list of additional configuration files referenced using\n        # the include keyword\n        configs = []\n\n        # Group Assignments\n        group_tags = {}\n\n        # Track our entries to preload\n        preloaded = []\n\n        try:\n            # Load our data (safely)\n            result = yaml.load(content, Loader=yaml.SafeLoader)\n\n        except (\n            AttributeError,\n            yaml.parser.ParserError,\n            yaml.error.MarkedYAMLError,\n        ) as e:\n            # Invalid content\n            ConfigBase.logger.error(\"Invalid Apprise YAML data specified.\")\n            ConfigBase.logger.debug(f\"YAML Exception:{os.linesep}{e}\")\n            return ([], [])\n\n        if not isinstance(result, dict):\n            # Invalid content\n            ConfigBase.logger.error(\n                \"Invalid Apprise YAML based configuration specified.\"\n            )\n            return ([], [])\n\n        # YAML Version\n        version = result.get(\"version\", 1)\n        if version != 1:\n            # Invalid syntax\n            ConfigBase.logger.error(\n                f\"Invalid Apprise YAML version specified {version}.\"\n            )\n            return ([], [])\n\n        #\n        # global asset object\n        #\n        asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n\n        # Prepare our default timezone\n        default_timezone = asset.tzinfo\n\n        # Acquire our asset tokens\n        tokens = result.get(\"asset\", None)\n        if tokens and isinstance(tokens, dict):\n            raw_tz = tokens.get(\"timezone\", tokens.get(\"tz\"))\n            if isinstance(raw_tz, str):\n                default_timezone = zoneinfo(re.sub(r\"[^\\w/-]+\", \"\", raw_tz))\n                if not default_timezone:\n                    ConfigBase.logger.warning(\n                        'Ignored invalid timezone \"%s\"', raw_tz)\n                    default_timezone = asset.tzinfo\n                else:\n                    asset._tzinfo = default_timezone\n\n            elif raw_tz is not None:\n                ConfigBase.logger.warning(\n                    'Ignored invalid timezone \"%r\"', raw_tz)\n\n            # Iterate over remaining tokens\n            for k, v in tokens.items():\n                if k.startswith(\"_\") or k.endswith(\"_\"):\n                    # Entries are considered reserved if they start or end\n                    # with an underscore\n                    ConfigBase.logger.warning(f'Ignored asset key \"{k}\".')\n                    continue\n\n                if not (\n                    hasattr(asset, k)\n                    and isinstance(getattr(asset, k), (bool, str))\n                ):\n\n                    # We can't set a function or non-string set value\n                    ConfigBase.logger.warning(f'Invalid asset key \"{k}\".')\n                    continue\n\n                if v is None:\n                    # Convert to an empty string\n                    v = \"\"\n\n                if isinstance(v, (bool, str)) and isinstance(\n                    getattr(asset, k), bool\n                ):\n\n                    # If the object in the Asset is a boolean, then\n                    # we want to convert the specified string to\n                    # match that.\n                    setattr(asset, k, parse_bool(v))\n\n                elif isinstance(v, str):\n                    # Set our asset object with the new value\n                    setattr(asset, k, v.strip())\n\n                else:\n                    # we must set strings with a string\n                    ConfigBase.logger.warning(f'Invalid asset value to \"{k}\".')\n                    continue\n        #\n        # global tag root directive\n        #\n        global_tags = set()\n\n        tags = result.get(\"tag\", result.get(\"tags\", None))\n        if tags and isinstance(tags, (list, tuple, str)):\n            # Store any preset tags\n            global_tags = set(parse_list(tags, cast=str))\n\n        #\n        # groups root directive\n        #\n        groups = result.get(\"groups\", None)\n        if isinstance(groups, dict):\n            #\n            # Dictionary\n            #\n            for groups_, tags in groups.items():\n                for group in parse_list(groups_, cast=str):\n                    if isinstance(tags, (list, tuple)):\n                        tags_ = set()\n                        for e in tags:\n                            if isinstance(e, dict):\n                                tags_ |= set(e.keys())\n                            else:\n                                tags_ |= set(parse_list(e, cast=str))\n\n                        # Final assignment\n                        tags = tags_\n\n                    else:\n                        tags = set(parse_list(tags, cast=str))\n\n                    if group not in group_tags:\n                        group_tags[group] = tags\n\n                    else:\n                        group_tags[group] |= tags\n\n        elif isinstance(groups, (list, tuple)):\n            #\n            # List of Dictionaries\n            #\n\n            # Iterate over each group defined and store it\n            for no, entry in enumerate(groups):\n                if not isinstance(entry, dict):\n                    ConfigBase.logger.warning(\n                        f\"No assignment for group {entry}, entry #{no + 1}\"\n                    )\n                    continue\n\n                for groups_, tags in entry.items():\n                    for group in parse_list(groups_, cast=str):\n                        if isinstance(tags, (list, tuple)):\n                            tags_ = set()\n                            for e in tags:\n                                if isinstance(e, dict):\n                                    tags_ |= set(e.keys())\n                                else:\n                                    tags_ |= set(parse_list(e, cast=str))\n\n                            # Final assignment\n                            tags = tags_\n\n                        else:\n                            tags = set(parse_list(tags, cast=str))\n\n                        if group not in group_tags:\n                            group_tags[group] = tags\n\n                        else:\n                            group_tags[group] |= tags\n\n        # include root directive\n        #\n        includes = result.get(\"include\", None)\n        if isinstance(includes, str):\n            # Support a single inline string or multiple ones separated by a\n            # comma and/or space\n            includes = parse_urls(includes)\n\n        elif not isinstance(includes, (list, tuple)):\n            # Not a problem; we simply have no includes\n            includes = []\n\n        # Iterate over each config URL\n        for _no, url in enumerate(includes):\n\n            if isinstance(url, str):\n                # Support a single inline string or multiple ones separated by\n                # a comma and/or space\n                configs.extend(parse_urls(url))\n\n            elif isinstance(url, dict):\n                # Store the url and ignore arguments associated\n                configs.extend(u for u in url)\n\n        #\n        # urls root directive\n        #\n        urls = result.get(\"urls\", None)\n        if not isinstance(urls, (list, tuple)):\n            # Not a problem; we simply have no urls\n            urls = []\n\n        # Iterate over each URL\n        for no, url in enumerate(urls):\n\n            # Our results object is what we use to instantiate our object if\n            # we can. Reset it to None on each iteration\n            results = []\n\n            # CWE-312 (Secure Logging) Handling\n            loggable_url = url if not asset.secure_logging else cwe312_url(url)\n\n            if isinstance(url, str):\n                # We're just a simple URL string...\n                schema = GET_SCHEMA_RE.match(url)\n                if schema is None:\n                    # Log invalid entries so that maintainer of config\n                    # config file at least has something to take action\n                    # with.\n                    ConfigBase.logger.warning(\n                        f\"Invalid URL {loggable_url}, entry #{no + 1}\"\n                    )\n                    continue\n\n                # We found a valid schema worthy of tracking; store it's\n                # details:\n                results_ = plugins.url_to_dict(\n                    url, secure_logging=asset.secure_logging\n                )\n                if results_ is None:\n                    ConfigBase.logger.warning(\n                        f\"Unparseable URL {loggable_url}, entry #{no + 1}\"\n                    )\n                    continue\n\n                # add our results to our global set\n                results.append(results_)\n\n            elif isinstance(url, dict):\n                # We are a url string with additional unescaped options. In\n                # this case we want to iterate over all of our options so we\n                # can at least tell the end user what entries were ignored\n                # due to errors\n\n                it = iter(url.items())\n\n                # Track the URL to-load\n                url_ = None\n\n                # Track last acquired schema\n                schema = None\n\n                for key, tokens_ in it:\n                    # Test our schema\n                    schema_ = GET_SCHEMA_RE.match(key)\n                    if schema_ is None:\n                        # Log invalid entries so that maintainer of config\n                        # config file at least has something to take action\n                        # with.\n                        ConfigBase.logger.warning(\n                            f\"Ignored entry {key} found under urls, entry\"\n                            f\" #{no + 1}\"\n                        )\n                        continue\n\n                    # Store our schema\n                    schema = schema_.group(\"schema\").lower()\n\n                    # Store our URL and Schema Regex\n                    url_ = key\n\n                    # Update our token assignment\n                    tokens = tokens_\n\n                    # We're done\n                    break\n\n                if url_ is None:\n                    # the loop above failed to match anything\n                    ConfigBase.logger.warning(\n                        f\"Unsupported URL, entry #{no + 1}\"\n                    )\n                    continue\n\n                results_ = plugins.url_to_dict(\n                    url_, secure_logging=asset.secure_logging\n                )\n                if results_ is None:\n                    # Setup dictionary\n                    results_ = {\n                        # Minimum requirements\n                        \"schema\": schema,\n                    }\n\n                if isinstance(tokens, (list, tuple, set)):\n                    # populate and/or override any results populated by\n                    # parse_url()\n                    for entries in tokens:\n                        # Copy ourselves a template of our parsed URL as a base\n                        # to work with\n                        r = results_.copy()\n\n                        # We are a url string with additional unescaped options\n                        if isinstance(entries, dict):\n                            url_, tokens = next(iter(url.items()))\n\n                            # Tags you just can't over-ride\n                            if \"schema\" in entries:\n                                del entries[\"schema\"]\n\n                            # support our special tokens (if they're present)\n                            if schema in N_MGR:\n                                entries = ConfigBase._special_token_handler(\n                                    schema, entries\n                                )\n\n                            # Extend our dictionary with our new entries\n                            r.update(entries)\n\n                            # add our results to our global set\n                            results.append(r)\n\n                elif isinstance(tokens, dict):\n                    # support our special tokens (if they're present)\n                    if schema in N_MGR:\n                        tokens = ConfigBase._special_token_handler(\n                            schema, tokens\n                        )\n\n                    # Copy ourselves a template of our parsed URL as a base to\n                    # work with\n                    r = results_.copy()\n\n                    # add our result set\n                    r.update(tokens)\n\n                    # add our results to our global set\n                    results.append(r)\n\n                else:\n                    # add our results to our global set\n                    results.append(results_)\n\n            else:\n                # Unsupported\n                ConfigBase.logger.warning(\n                    f\"Unsupported Apprise YAML entry #{no + 1}\"\n                )\n                continue\n\n            # Track our entries\n            entry = 0\n\n            # Prepare our results for post-processing\n            results = deque(results)\n\n            while len(results):\n                # Increment our entry count\n                entry += 1\n\n                # Grab our first item\n                results_ = results.popleft()\n\n                if results_[\"schema\"] not in N_MGR:\n                    # the arguments are invalid or can not be used.\n                    ConfigBase.logger.warning(\n                        \"An invalid Apprise schema ({}) in YAML configuration \"\n                        \"entry #{}, item #{}\".format(\n                            results_[\"schema\"], no + 1, entry\n                        )\n                    )\n                    continue\n\n                # tag is a special keyword that is managed by Apprise object.\n                # The below ensures our tags are set correctly\n                if \"tag\" in results_:\n                    # Tidy our list up\n                    results_[\"tag\"] = (\n                        set(parse_list(results_[\"tag\"], cast=str))\n                        | global_tags\n                    )\n                    if \"tags\" in results_:\n                        ConfigBase.logger.warning((\n                            \"URL #{}: {} contains both 'tag' and 'tags' \"\n                            \"keyword\").format(no + 1, url))\n                        del results_[\"tags\"]\n\n                elif \"tags\" in results_:\n                    # Tidy our list up\n                    results_[\"tag\"] = (\n                        set(parse_list(results_[\"tags\"], cast=str))\n                        | global_tags\n                    )\n                    # Should not carry forward\n                    del results_[\"tags\"]\n\n                else:\n                    # Just use the global settings\n                    results_[\"tag\"] = global_tags\n\n                for key in list(results_.keys()):\n                    # Strip out any tokens we know that we can't accept and\n                    # warn the user\n                    match = VALID_TOKEN.match(key)\n                    if not match:\n                        ConfigBase.logger.warning(\n                            f\"Ignoring invalid token ({key}) found in YAML \"\n                            f\"configuration entry #{no + 1}, item #{entry}\"\n                        )\n                        del results_[key]\n\n                if ConfigBase.logger.isEnabledFor(logging.TRACE):\n                    ConfigBase.logger.trace(\n                        \"URL #%d: %s unpacked as:%s%s\",\n                        no + 1,\n                        url,\n                        os.linesep,\n                        os.linesep.join(\n                            [f'{k}=\"{a}\"' for k, a in results_.items()]),\n                    )\n\n                # Prepare our Asset Object\n                results_[\"asset\"] = asset\n\n                # Handle post processing of result set\n                results_ = URLBase.post_process_parse_url_results(results_)\n\n                # Store our preloaded entries\n                preloaded.append({\n                    \"results\": results_,\n                    \"entry\": no + 1,\n                    \"item\": entry,\n                })\n\n        #\n        # Normalize Tag Groups\n        # - Expand Groups of Groups so that they don't exist\n        #\n        ConfigBase.__normalize_tag_groups(group_tags)\n\n        #\n        # URL Processing\n        #\n        for entry in preloaded:\n            # Point to our results entry for easier reference below\n            results = entry[\"results\"]\n\n            #\n            # Apply our tag groups if they're defined\n            #\n            for group, tags in group_tags.items():\n                # Detect if anything assigned to this tag also maps back to a\n                # group.  If so we want to add the group to our list\n                if next(\n                    (True for tag in results[\"tag\"] if tag in tags), False\n                ):\n                    results[\"tag\"].add(group)\n\n            # Now we generate our plugin\n            try:\n                # Attempt to create an instance of our plugin using the\n                # parsed URL information\n                plugin = N_MGR[results[\"schema\"]](**results)\n\n                # Create log entry of loaded URL\n                ConfigBase.logger.debug(\n                    \"Loaded URL: %s\",\n                    plugin.url(privacy=results[\"asset\"].secure_logging),\n                )\n\n            except Exception as e:\n                # the arguments are invalid or can not be used.\n                ConfigBase.logger.warning(\n                    \"Could not load Apprise YAML configuration \"\n                    \"entry #{}, item #{}\".format(entry[\"entry\"], entry[\"item\"])\n                )\n                ConfigBase.logger.debug(f\"Loading Exception: {e!s}\")\n                continue\n\n            # if we reach here, we successfully loaded our data\n            servers.append(plugin)\n\n        preloaded.clear()\n        return (servers, configs)\n\n    def pop(self, index: int = -1) -> object:\n        \"\"\"Removes an indexed Notification Service from the stack and returns\n        it.\n\n        By default, the last element of the list is removed.\n        \"\"\"\n\n        if not isinstance(self._cached_servers, list):\n            # Generate ourselves a list of content we can pull from\n            self.servers()\n\n        # Pop the element off of the stack\n        return self._cached_servers.pop(index)\n\n    def clear_cache(self) -> None:\n        \"\"\"Cleans cache\"\"\"\n        self._cached_servers = None\n        self._cached_time = None\n\n    @staticmethod\n    def _special_token_handler(\n        schema: str,\n        tokens: dict[str, object],\n    ) -> dict[str, object]:\n        \"\"\"This function takes a list of tokens and updates them to no longer\n        include any special tokens such as +,-, and :\n\n        - schema must be a valid schema of a supported plugin type\n        - tokens must be a dictionary containing the yaml entries parsed.\n\n        The idea here is we can post process a set of tokens provided in\n        a YAML file where the user provided some of the special keywords.\n\n        We effectivley look up what these keywords map to their appropriate\n        value they're expected\n        \"\"\"\n        # Create a copy of our dictionary\n        tokens = tokens.copy()\n\n        for kw, meta in N_MGR[schema].template_kwargs.items():\n\n            # Determine our prefix:\n            prefix = meta.get(\"prefix\", \"+\")\n\n            # Detect any matches\n            matches = {\n                k[1:]: str(v)\n                for k, v in tokens.items()\n                if k.startswith(prefix)\n            }\n\n            if not matches:\n                # we're done with this entry\n                continue\n\n            if not isinstance(tokens.get(kw), dict):\n                # Invalid; correct it\n                tokens[kw] = {}\n\n            # strip out processed tokens\n            tokens = {\n                k: v for k, v in tokens.items() if not k.startswith(prefix)\n            }\n\n            # Update our entries\n            tokens[kw].update(matches)\n\n        # Now map our tokens accordingly to the class templates defined by\n        # each service.\n        #\n        # This is specifically used for YAML file parsing.  It allows a user to\n        # define an entry such as:\n        #\n        # urls:\n        #   - mailto://user:pass@domain:\n        #       - to: user1@hotmail.com\n        #       - to: user2@hotmail.com\n        #\n        # Under the hood, the NotifyEmail() class does not parse the `to`\n        # argument. It's contents needs to be mapped to `targets`.  This is\n        # defined in the class via the `template_args` and template_tokens`\n        # section.\n        #\n        # This function here allows these mappings to take place within the\n        # YAML file as independant arguments.\n        class_templates = plugins.details(N_MGR[schema])\n\n        for key in list(tokens.keys()):\n\n            if key not in class_templates[\"args\"]:\n                # No need to handle non-arg entries\n                continue\n\n            # get our `map_to` and/or 'alias_of' value (if it exists)\n            map_to = class_templates[\"args\"][key].get(\n                \"alias_of\", class_templates[\"args\"][key].get(\"map_to\", \"\")\n            )\n\n            if map_to == key:\n                # We're already good as we are now\n                continue\n\n            if map_to in class_templates[\"tokens\"]:\n                meta = class_templates[\"tokens\"][map_to]\n\n            else:\n                meta = class_templates[\"args\"].get(\n                    map_to, class_templates[\"args\"][key]\n                )\n\n            # Perform a translation/mapping if our code reaches here\n            value = tokens[key]\n            del tokens[key]\n\n            # Detect if we're dealign with a list or not\n            is_list = re.search(r\"^list:.*\", meta.get(\"type\"), re.IGNORECASE)\n\n            if map_to not in tokens:\n                tokens[map_to] = [] if is_list else meta.get(\"default\")\n\n            elif is_list and not isinstance(tokens.get(map_to), list):\n                # Convert ourselves to a list if we aren't already\n                tokens[map_to] = [tokens[map_to]]\n\n            # Type Conversion\n            if re.search(\n                r\"^(choice:)?string\", meta.get(\"type\"), re.IGNORECASE\n            ) and not isinstance(value, str):\n\n                # Ensure our format is as expected\n                value = str(value)\n\n            # Apply any further translations if required (absolute map)\n            # This is the case when an arg maps to a token which further\n            # maps to a different function arg on the class constructor\n            abs_map = meta.get(\"map_to\", map_to)\n\n            # Set our token as how it was provided by the configuration\n            if isinstance(tokens.get(map_to), list):\n                tokens[abs_map].append(value)\n\n            else:\n                tokens[abs_map] = value\n\n        # Return our tokens\n        return tokens\n\n    def __getitem__(self, index: int) -> object:\n        \"\"\"Returns the indexed server entry associated with the loaded\n        notification servers.\"\"\"\n        if not isinstance(self._cached_servers, list):\n            # Generate ourselves a list of content we can pull from\n            self.servers()\n\n        return self._cached_servers[index]\n\n    def __iter__(self) -> object:\n        \"\"\"Returns an iterator to our server list.\"\"\"\n        if not isinstance(self._cached_servers, list):\n            # Generate ourselves a list of content we can pull from\n            self.servers()\n\n        return iter(self._cached_servers)\n\n    def __len__(self) -> int:\n        \"\"\"Returns the total number of servers loaded.\"\"\"\n        if not isinstance(self._cached_servers, list):\n            # Generate ourselves a list of content we can pull from\n            self.servers()\n\n        return len(self._cached_servers)\n\n    def __bool__(self) -> bool:\n        \"\"\"Allows the Apprise object to be wrapped in an 'if statement'.\n\n        True is returned if our content was downloaded correctly.\n        \"\"\"\n        if not isinstance(self._cached_servers, list):\n            # Generate ourselves a list of content we can pull from\n            self.servers()\n\n        return bool(self._cached_servers)\n"
  },
  {
    "path": "apprise/config/file.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport os\nimport re\n\nfrom ..common import ConfigFormat, ContentIncludeMode\nfrom ..locale import gettext_lazy as _\nfrom ..utils.disk import path_decode\nfrom .base import ConfigBase\n\n\nclass ConfigFile(ConfigBase):\n    \"\"\"A wrapper for File based configuration sources.\"\"\"\n\n    # The default descriptive name associated with the service\n    service_name = _(\"Local File\")\n\n    # The default protocol\n    protocol = \"file\"\n\n    # Configuration file inclusion can only be of the same type\n    allow_cross_includes = ContentIncludeMode.STRICT\n\n    def __init__(self, path, **kwargs):\n        \"\"\"Initialize File Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # Store our file path as it was set\n        self.path = path_decode(path)\n\n        # Track the file as it was saved\n        self.__original_path = os.path.normpath(path)\n\n        # Update the config path to be relative to our file we just loaded\n        self.config_path = os.path.dirname(self.path)\n\n        return\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Prepare our cache value\n        if isinstance(self.cache, bool) or not self.cache:\n            cache = \"yes\" if self.cache else \"no\"\n\n        else:\n            cache = int(self.cache)\n\n        # Define any URL parameters\n        params = {\n            \"encoding\": self.encoding,\n            \"cache\": cache,\n        }\n\n        if self.config_format:\n            # A format was enforced; make sure it's passed back with the url\n            params[\"format\"] = self.config_format\n\n        return \"file://{path}{params}\".format(\n            path=self.quote(self.__original_path),\n            params=f\"?{self.urlencode(params)}\" if params else \"\",\n        )\n\n    def read(self, **kwargs):\n        \"\"\"Perform retrieval of the configuration based on the specified\n        request.\"\"\"\n\n        response = None\n\n        try:\n            if (\n                self.max_buffer_size > 0\n                and os.path.getsize(self.path) > self.max_buffer_size\n            ):\n\n                # Content exceeds maximum buffer size\n                self.logger.error(\n                    \"File size exceeds maximum allowable buffer length\"\n                    f\" ({int(self.max_buffer_size / 1024)}KB).\"\n                )\n                return None\n\n        except OSError:\n            # getsize() can throw this acception if the file is missing\n            # and or simply isn't accessible\n            self.logger.error(f\"File is not accessible: {self.path}\")\n            return None\n\n        # Always call throttle before any server i/o is made\n        self.throttle()\n\n        try:\n            with open(self.path, encoding=self.encoding) as f:\n                # Store our content for parsing\n                response = f.read()\n\n        except (ValueError, UnicodeDecodeError):\n            # A result of our strict encoding check; if we receive this\n            # then the file we're opening is not something we can\n            # understand the encoding of..\n\n            self.logger.error(\n                f\"File not using expected encoding ({self.encoding}) :\"\n                f\" {self.path}\"\n            )\n            return None\n\n        except OSError:\n            # IOError is present for backwards compatibility with Python\n            # versions older then 3.3.  >= 3.3 throw OSError now.\n\n            # Could not open and/or read the file; this is not a problem since\n            # we scan a lot of default paths.\n            self.logger.error(f\"File can not be opened for read: {self.path}\")\n            return None\n\n        # Detect config format based on file extension if it isn't already\n        # enforced\n        if (\n            self.config_format is None\n            and re.match(r\"^.*\\.ya?ml\\s*$\", self.path, re.I) is not None\n        ):\n\n            # YAML Filename Detected\n            self.default_config_format = ConfigFormat.YAML\n\n        # Return our response object\n        return response\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL so that we can handle all different file paths and\n        return it as our path object.\"\"\"\n\n        results = ConfigBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early; it's not a good URL\n            return results\n\n        match = re.match(r\"[a-z0-9]+://(?P<path>[^?]+)(\\?.*)?\", url, re.I)\n        if not match:\n            return None\n\n        results[\"path\"] = ConfigFile.unquote(match.group(\"path\"))\n        return results\n"
  },
  {
    "path": "apprise/config/http.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport re\n\nimport requests\n\nfrom ..common import ConfigFormat, ContentIncludeMode\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom .base import ConfigBase\n\n# Support YAML formats\n# text/yaml\n# text/x-yaml\n# application/yaml\n# application/x-yaml\nMIME_IS_YAML = re.compile(r\"(text|application)/(x-)?yaml\", re.I)\n\n# Support TEXT formats\n# text/plain\n# text/html\nMIME_IS_TEXT = re.compile(r\"text/(plain|html)\", re.I)\n\n\nclass ConfigHTTP(ConfigBase):\n    \"\"\"A wrapper for HTTP based configuration sources.\"\"\"\n\n    # The default descriptive name associated with the service\n    service_name = _(\"Web Based\")\n\n    # The default protocol\n    protocol = \"http\"\n\n    # The default secure protocol\n    secure_protocol = \"https\"\n\n    # If an HTTP error occurs, define the number of characters you still want\n    # to read back.  This is useful for debugging purposes, but nothing else.\n    # The idea behind enforcing this kind of restriction is to prevent abuse\n    # from queries to services that may be untrusted.\n    max_error_buffer_size = 2048\n\n    # Configuration file inclusion can always include this type\n    allow_cross_includes = ContentIncludeMode.ALWAYS\n\n    def __init__(self, headers=None, **kwargs):\n        \"\"\"Initialize HTTP Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.schema = \"https\" if self.secure else \"http\"\n\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"/\"\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        return\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Prepare our cache value\n        if isinstance(self.cache, bool) or not self.cache:\n            cache = \"yes\" if self.cache else \"no\"\n\n        else:\n            cache = int(self.cache)\n\n        # Define any arguments set\n        params = {\n            \"encoding\": self.encoding,\n            \"cache\": cache,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.config_format:\n            # A format was enforced; make sure it's passed back with the url\n            params[\"format\"] = self.config_format\n\n        # Append our headers into our args\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=self.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=self.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}{fullpath}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            hostname=self.quote(self.host, safe=\"\"),\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=self.quote(self.fullpath, safe=\"/\"),\n            params=self.urlencode(params),\n        )\n\n    def read(self, **kwargs):\n        \"\"\"Perform retrieval of the configuration based on the specified\n        request.\"\"\"\n\n        # prepare XML Object\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        url = f\"{self.schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        url += self.fullpath\n\n        self.logger.debug(\n            f\"HTTP POST URL: {url} (cert_verify={self.verify_certificate!r})\"\n        )\n\n        # Prepare our response object\n        response = None\n\n        # Where our request object will temporarily live.\n        r = None\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            # Make our request\n            with requests.post(\n                url,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n                stream=True,\n            ) as r:\n\n                # Handle Errors\n                r.raise_for_status()\n\n                # Get our file-size (if known)\n                try:\n                    file_size = int(r.headers.get(\"Content-Length\", \"0\"))\n                except (TypeError, ValueError):\n                    # Handle edge case where Content-Length is a bad value\n                    file_size = 0\n\n                # Store our response\n                if (\n                    self.max_buffer_size > 0\n                    and file_size > self.max_buffer_size\n                ):\n\n                    # Provide warning of data truncation\n                    self.logger.error(\n                        \"HTTP config response exceeds maximum buffer length \"\n                        f\"({int(self.max_buffer_size / 1024)}KB);\"\n                    )\n\n                    # Return None - buffer execeeded\n                    return None\n\n                # Store our result (but no more than our buffer length)\n                response = r.text[: self.max_buffer_size + 1]\n\n                # Verify that our content did not exceed the buffer size:\n                if len(response) > self.max_buffer_size:\n                    # Provide warning of data truncation\n                    self.logger.error(\n                        \"HTTP config response exceeds maximum buffer length \"\n                        f\"({int(self.max_buffer_size / 1024)}KB);\"\n                    )\n\n                    # Return None - buffer execeeded\n                    return None\n\n                # Detect config format based on mime if the format isn't\n                # already enforced\n                content_type = r.headers.get(\n                    \"Content-Type\", \"application/octet-stream\"\n                )\n                if self.config_format is None and content_type:\n                    if MIME_IS_YAML.match(content_type) is not None:\n\n                        # YAML data detected based on header content\n                        self.default_config_format = ConfigFormat.YAML\n\n                    elif MIME_IS_TEXT.match(content_type) is not None:\n\n                        # TEXT data detected based on header content\n                        self.default_config_format = ConfigFormat.TEXT\n\n        except requests.RequestException as e:\n            self.logger.error(\n                \"A Connection error occurred retrieving HTTP \"\n                f\"configuration from {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return None (signifying a failure)\n            return None\n\n        # Return our response object\n        return response\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = ConfigBase.parse_url(url)\n\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set\n        results[\"headers\"] = results[\"qsd-\"]\n        results[\"headers\"].update(results[\"qsd+\"])\n\n        return results\n"
  },
  {
    "path": "apprise/config/memory.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom ..locale import gettext_lazy as _\nfrom .base import ConfigBase\n\n\nclass ConfigMemory(ConfigBase):\n    \"\"\"For information that was loaded from memory and does not persist\n    anywhere.\"\"\"\n\n    # The default descriptive name associated with the service\n    service_name = _(\"Memory\")\n\n    # The default protocol\n    protocol = \"memory\"\n\n    def __init__(self, content, **kwargs):\n        \"\"\"Initialize Memory Object.\n\n        Memory objects just store the raw configuration in memory.  There is no\n        external reference point. It's always considered cached.\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # Store our raw config into memory\n        self.content = content\n\n        if self.config_format is None:\n            # Detect our format if possible\n            self.config_format = ConfigMemory.detect_config_format(\n                self.content\n            )\n\n        return\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        return \"memory://\"\n\n    def read(self, **kwargs):\n        \"\"\"Simply return content stored into memory.\"\"\"\n\n        return self.content\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Memory objects have no parseable URL.\"\"\"\n        # These URLs can not be parsed\n        return None\n"
  },
  {
    "path": "apprise/conversion.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom html.parser import HTMLParser\nimport re\n\nfrom markdown import markdown\n\nfrom .common import NotifyFormat\nfrom .url import URLBase\n\n\ndef convert_between(from_format, to_format, content):\n    \"\"\"Converts between different suported formats. If no conversion exists, or\n    the selected one fails, the original text will be returned.\n\n    This function returns the content translated (if required)\n    \"\"\"\n\n    converters = {\n        (NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html,\n        (NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html,\n        (NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text,\n        # For now; use same converter for Markdown support\n        (NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text,\n    }\n\n    convert = converters.get((from_format, to_format))\n    return convert(content) if convert else content\n\n\ndef markdown_to_html(content):\n    \"\"\"Converts specified content from markdown to HTML.\"\"\"\n    return markdown(\n        content,\n        extensions=[\"markdown.extensions.nl2br\", \"markdown.extensions.tables\"],\n    )\n\n\ndef text_to_html(content):\n    \"\"\"Converts specified content from plain text to HTML.\"\"\"\n\n    # First eliminate any carriage returns\n    return URLBase.escape_html(content, convert_new_lines=True)\n\n\ndef html_to_text(content):\n    \"\"\"Converts a content from HTML to plain text.\"\"\"\n\n    parser = HTMLConverter()\n    parser.feed(content)\n    parser.close()\n    return parser.converted\n\n\nclass HTMLConverter(HTMLParser):\n    \"\"\"An HTML to plain text converter tuned for email messages.\"\"\"\n\n    # The following tags must start on a new line\n    BLOCK_TAGS = (\n        \"p\",\n        \"h1\",\n        \"h2\",\n        \"h3\",\n        \"h4\",\n        \"h5\",\n        \"h6\",\n        \"div\",\n        \"td\",\n        \"th\",\n        \"code\",\n        \"pre\",\n        \"label\",\n        \"li\",\n    )\n\n    # the folowing tags ignore any internal text\n    IGNORE_TAGS = (\n        \"form\",\n        \"input\",\n        \"textarea\",\n        \"select\",\n        \"ul\",\n        \"ol\",\n        \"style\",\n        \"link\",\n        \"meta\",\n        \"title\",\n        \"html\",\n        \"head\",\n        \"script\",\n    )\n\n    # Condense Whitespace\n    WS_TRIM = re.compile(r\"[\\s]+\", re.DOTALL | re.MULTILINE)\n\n    # Sentinel value for block tag boundaries, which may be consolidated into a\n    # single line break.\n    BLOCK_END = {}\n\n    def __init__(self, **kwargs):\n        super().__init__(**kwargs)\n\n        # Shoudl we store the text content or not?\n        self._do_store = True\n\n        # Initialize internal result list\n        self._result = []\n\n        # Initialize public result field (not populated until close() is\n        # called)\n        self.converted = \"\"\n\n    def close(self):\n        string = \"\".join(self._finalize(self._result))\n        self.converted = string.strip()\n\n    def _finalize(self, result):\n        \"\"\"Combines and strips consecutive strings, then converts consecutive\n        block ends into singleton newlines.\n\n        [ {be} \" Hello \" {be} {be} \" World!\" ] -> \"\\nHello\\nWorld!\"\n        \"\"\"\n\n        # None means the last visited item was a block end.\n        accum = None\n\n        for item in result:\n            if item == self.BLOCK_END:\n                # Multiple consecutive block ends; do nothing.\n                if accum is None:\n                    continue\n\n                # First block end; yield the current string, plus a newline.\n                yield accum.strip() + \"\\n\"\n                accum = None\n\n            # Multiple consecutive strings; combine them.\n            elif accum is not None:\n                accum += item\n\n            # First consecutive string; store it.\n            else:\n                accum = item\n\n        # Yield the last string if we have not already done so.\n        if accum is not None:\n            yield accum.strip()\n\n    def handle_data(self, data, *args, **kwargs):\n        \"\"\"Store our data if it is not on the ignore list.\"\"\"\n\n        # initialize our previous flag\n        if self._do_store:\n\n            # Tidy our whitespace\n            content = self.WS_TRIM.sub(\" \", data)\n            self._result.append(content)\n\n    def handle_starttag(self, tag, attrs):\n        \"\"\"Process our starting HTML Tag.\"\"\"\n        # Toggle initial states\n        self._do_store = tag not in self.IGNORE_TAGS\n\n        if tag in self.BLOCK_TAGS:\n            self._result.append(self.BLOCK_END)\n\n        if tag == \"li\":\n            self._result.append(\"- \")\n\n        elif tag == \"br\":\n            self._result.append(\"\\n\")\n\n        elif tag == \"hr\":\n            if self._result and isinstance(self._result[-1], str):\n                self._result[-1] = self._result[-1].rstrip(\" \")\n\n            self._result.append(\"\\n---\\n\")\n\n        elif tag == \"blockquote\":\n            self._result.append(\" >\")\n\n    def handle_endtag(self, tag):\n        \"\"\"Edge case handling of open/close tags.\"\"\"\n        self._do_store = True\n\n        if tag in self.BLOCK_TAGS:\n            self._result.append(self.BLOCK_END)\n"
  },
  {
    "path": "apprise/decorators/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom .notify import notify\n\n__all__ = [\"notify\"]\n"
  },
  {
    "path": "apprise/decorators/base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport inspect\n\nfrom .. import common\nfrom ..logger import logger\nfrom ..manager_plugins import NotificationManager\nfrom ..plugins.base import NotifyBase\nfrom ..utils.logic import dict_full_update\nfrom ..utils.parse import URL_DETAILS_RE, parse_url, url_assembly\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\nclass CustomNotifyPlugin(NotifyBase):\n    \"\"\"Apprise Custom Plugin Hook.\n\n    This gets initialized based on @notify decorator definitions\n    \"\"\"\n\n    # Our Custom notification; identify the URL users can go to learn\n    # more about the service this wrapper supports:\n    service_url = \"https://appriseit.com/library/extending/decorator/\"\n\n    # Over-ride our category since this inheritance of the NotifyBase class\n    # should be treated differently.\n    category = \"custom\"\n\n    # Support Attachments\n    attachment_support = True\n\n    # Allow persistent storage support\n    storage_mode = common.PersistentStoreMode.AUTO\n\n    # Define object templates\n    templates = (\"{schema}://\",)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns arguments retrieved.\"\"\"\n        return parse_url(url, verify_host=False, simple=True)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"General URL assembly.\"\"\"\n        return f\"{self.secure_protocol}://\"\n\n    @staticmethod\n    def instantiate_plugin(url, send_func, name=None):\n        \"\"\"The function used to add a new notification plugin based on the\n        schema parsed from the provided URL into our supported matrix\n        structure.\"\"\"\n\n        if not isinstance(url, str):\n            msg = (\n                f\"An invalid custom notify url/schema ({url}) provided in \"\n                f\"function {send_func.__name__}.\"\n            )\n            logger.warning(msg)\n            return None\n\n        # Validate that our schema is okay\n        re_match = URL_DETAILS_RE.match(url)\n        if not re_match:\n            msg = (\n                f\"An invalid custom notify url/schema ({url}) provided in \"\n                f\"function {send_func.__name__}.\"\n            )\n            logger.warning(msg)\n            return None\n\n        # Acquire our schema\n        schema = re_match.group(\"schema\").lower()\n\n        if not re_match.group(\"base\"):\n            url = f\"{schema}://\"\n\n        # Keep a default set of arguments to apply to all called references\n        base_args = parse_url(\n            url, default_schema=schema, verify_host=False, simple=True\n        )\n\n        if schema in N_MGR:\n            # we're already handling this object\n            msg = (\n                f\"The schema ({url}) is already defined and could not be \"\n                f\"loaded from custom notify function {send_func.__name__}.\"\n            )\n            logger.warning(msg)\n            return None\n\n        # We define our own custom wrapper class so that we can initialize\n        # some key default configuration values allowing calls to our\n        # `Apprise.details()` to correctly differentiate one custom plugin\n        # that was loaded from another\n        class CustomNotifyPluginWrapper(CustomNotifyPlugin):\n\n            # Our Service Name\n            service_name = (\n                name\n                if isinstance(name, str) and name\n                else f\"Custom - {schema}\"\n            )\n\n            # Store our matched schema\n            secure_protocol = schema\n\n            requirements = {\n                # Define our required packaging in order to work\n                \"details\": f\"Source: {inspect.getfile(send_func)}\"\n            }\n\n            # Assign our send() function\n            __send = staticmethod(send_func)\n\n            # Update our default arguments\n            _base_args = base_args\n\n            def __init__(self, **kwargs):\n                \"\"\"Our initialization.\"\"\"\n                #  init parent\n                super().__init__(**kwargs)\n\n                self._default_args = {}\n\n                # Some variables do not need to be set\n                kwargs.pop(\"secure\", None)\n\n                # Apply our updates based on what was parsed\n                dict_full_update(self._default_args, self._base_args)\n                dict_full_update(self._default_args, kwargs)\n\n                # Update our arguments (applying them to what we originally)\n                # initialized as\n                self._default_args[\"url\"] = url_assembly(**self._default_args)\n\n            def send(\n                self,\n                body,\n                title=\"\",\n                notify_type=common.NotifyType.INFO,\n                *args,\n                **kwargs,\n            ):\n                \"\"\"Our send() call which triggers our hook.\"\"\"\n\n                response = False\n                try:\n                    # Enforce a boolean response\n                    result = self.__send(\n                        body,\n                        title,\n                        notify_type.value,\n                        *args,\n                        meta=self._default_args,\n                        **kwargs,\n                    )\n\n                    # None and True are both considered successful\n                    # False however is passed along further upstream\n                    response = True if result is None else bool(result)\n\n                except Exception as e:\n                    # Unhandled Exception\n                    self.logger.warning(\n                        \"An exception occured sending a %s notification.\",\n                        N_MGR[self.secure_protocol].service_name,\n                    )\n                    self.logger.debug(\n                        \"%s Exception: %s\", N_MGR[self.secure_protocol], e)\n                    return False\n\n                if response:\n                    self.logger.info(\n                        \"Sent %s notification.\",\n                        N_MGR[self.secure_protocol].service_name,\n                    )\n                else:\n                    self.logger.warning(\n                        \"Failed to send %s notification.\",\n                        N_MGR[self.secure_protocol].service_name,\n                    )\n                return response\n\n        # Store our plugin into our core map file\n        return N_MGR.add(\n            plugin=CustomNotifyPluginWrapper,\n            schemas=schema,\n            send_func=send_func,\n            url=url,\n        )\n"
  },
  {
    "path": "apprise/decorators/notify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom .base import CustomNotifyPlugin\n\n\ndef notify(on, name=None):\n    \"\"\"\n    @notify decorator allows you to map functions you've defined to be loaded\n    as a regular notify by Apprise.  You must identify a protocol that\n    users will trigger your call by.\n\n        @notify(on=\"foobar\")\n        def your_declaration(body, title, notify_type, meta, *args, **kwargs):\n            ...\n\n    You can optionally provide the name to associate with the plugin which\n    is what calling functions via the API will receive.\n\n        @notify(on=\"foobar\", name=\"My Foobar Process\")\n        def your_action(body, title, notify_type, meta, *args, **kwargs):\n            ...\n\n    The meta variable is actually the processed URL contents found in\n    configuration files that landed you in this function you wrote in\n    the first place.  It's very easily tokenized already for you so\n    that you can bend the notification logic to your hearts content.\n\n        @notify(on=\"foobar\", name=\"My Foobar Process\")\n        def your_action(body, title, notify_type, body_format, meta, attach,\n                        *args, **kwargs):\n            ...\n\n    Arguments break down as follows:\n      body:        The message body associated with the notification\n      title:       The message title associated with the notification\n      notify_type: The message type (info, success, warning, and failure)\n      body_format: The format of the incoming notification body. This is\n                   either text, html, or markdown.\n      meta:        Combines the URL arguments specified on the `on` call\n                   with the ones loaded from a users configuration. This\n                   is a dictionary that presents itself like this:\n                    {\n                      'schema': 'http',\n                      'url': 'http://hostname',\n                      'host': 'hostname',\n\n                      'user': 'john',\n                      'password': 'doe',\n                      'port': 80,\n                      'path': '/',\n                      'fullpath': '/test.php',\n                      'query': 'test.php',\n\n                      'qsd': {'key': 'value', 'key2': 'value2'},\n\n                      'asset': <AppriseAsset>,\n                      'tag': set(),\n                    }\n\n                    Meta entries are ONLY present if found.  A simple URL\n                    such as foobar:// would only produce the following:\n                    {\n                      'schema': 'foobar',\n                      'url': 'foobar://',\n\n                      'asset': <AppriseAsset>,\n                      'tag': set(),\n                    }\n\n      attach:      An array AppriseAttachment objects (if any were provided)\n\n      body_format: Defaults to the expected format output; By default this\n                   will be TEXT unless over-ridden in the Apprise URL\n\n\n    If you don't intend on using all of the parameters, your @notify() call\n    # can be greatly simplified to just:\n\n        @notify(on=\"foobar\", name=\"My Foobar Process\")\n        def your_action(body, title, *args, **kwargs)\n\n    Always end your wrappers declaration with *args and **kwargs to be future\n    proof with newer versions of Apprise.\n\n    Your wrapper should return True if processed the send() function as you\n    expected and return False if not. If nothing is returned, then this is\n    treated as as success (True).\n\n    \"\"\"\n\n    def wrapper(func):\n        \"\"\"Instantiate our custom (notification) plugin.\"\"\"\n\n        # Generate\n        CustomNotifyPlugin.instantiate_plugin(\n            url=on, send_func=func, name=name\n        )\n\n        return func\n\n    return wrapper\n"
  },
  {
    "path": "apprise/emojis.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport re\nimport time\n\nfrom .logger import logger\n\n# All Emoji's are wrapped in this character\nDELIM = \":\"\n\n# the map simply contains the emoji that should be mapped to the regular\n# expression it should be swapped on.\n# This list was based on: https://github.com/ikatyang/emoji-cheat-sheet\nEMOJI_MAP = {\n    #\n    # Face Smiling\n    #\n    DELIM + r\"grinning\" + DELIM: \"😄\",\n    DELIM + r\"smile\" + DELIM: \"😄\",\n    DELIM + r\"(laughing|satisfied)\" + DELIM: \"😆\",\n    DELIM + r\"rofl\" + DELIM: \"🤣\",\n    DELIM + r\"slightly_smiling_face\" + DELIM: \"🙂\",\n    DELIM + r\"wink\" + DELIM: \"😉\",\n    DELIM + r\"innocent\" + DELIM: \"😇\",\n    DELIM + r\"smiley\" + DELIM: \"😃\",\n    DELIM + r\"grin\" + DELIM: \"😃\",\n    DELIM + r\"sweat_smile\" + DELIM: \"😅\",\n    DELIM + r\"joy\" + DELIM: \"😂\",\n    DELIM + r\"upside_down_face\" + DELIM: \"🙃\",\n    DELIM + r\"blush\" + DELIM: \"😊\",\n    #\n    # Face Affection\n    #\n    DELIM + r\"smiling_face_with_three_hearts\" + DELIM: \"🥰\",\n    DELIM + r\"star_struck\" + DELIM: \"🤩\",\n    DELIM + r\"kissing\" + DELIM: \"😗\",\n    DELIM + r\"kissing_closed_eyes\" + DELIM: \"😚\",\n    DELIM + r\"smiling_face_with_tear\" + DELIM: \"🥲\",\n    DELIM + r\"heart_eyes\" + DELIM: \"😍\",\n    DELIM + r\"kissing_heart\" + DELIM: \"😘\",\n    DELIM + r\"relaxed\" + DELIM: \"☺️\",\n    DELIM + r\"kissing_smiling_eyes\" + DELIM: \"😙\",\n    #\n    # Face Tongue\n    #\n    DELIM + r\"yum\" + DELIM: \"😋\",\n    DELIM + r\"stuck_out_tongue_winking_eye\" + DELIM: \"😜\",\n    DELIM + r\"stuck_out_tongue_closed_eyes\" + DELIM: \"😝\",\n    DELIM + r\"stuck_out_tongue\" + DELIM: \"😛\",\n    DELIM + r\"zany_face\" + DELIM: \"🤪\",\n    DELIM + r\"money_mouth_face\" + DELIM: \"🤑\",\n    #\n    # Face Hand\n    #\n    DELIM + r\"hugs\" + DELIM: \"🤗\",\n    DELIM + r\"shushing_face\" + DELIM: \"🤫\",\n    DELIM + r\"hand_over_mouth\" + DELIM: \"🤭\",\n    DELIM + r\"thinking\" + DELIM: \"🤔\",\n    #\n    # Face Neutral Skeptical\n    #\n    DELIM + r\"zipper_mouth_face\" + DELIM: \"🤐\",\n    DELIM + r\"neutral_face\" + DELIM: \"😐\",\n    DELIM + r\"no_mouth\" + DELIM: \"😶\",\n    DELIM + r\"smirk\" + DELIM: \"😏\",\n    DELIM + r\"roll_eyes\" + DELIM: \"🙄\",\n    DELIM + r\"face_exhaling\" + DELIM: \"😮‍💨\",\n    DELIM + r\"raised_eyebrow\" + DELIM: \"🤨\",\n    DELIM + r\"expressionless\" + DELIM: \"😑\",\n    DELIM + r\"face_in_clouds\" + DELIM: \"😶‍🌫️\",\n    DELIM + r\"unamused\" + DELIM: \"😒\",\n    DELIM + r\"grimacing\" + DELIM: \"😬\",\n    DELIM + r\"lying_face\" + DELIM: \"🤥\",\n    #\n    # Face Sleepy\n    #\n    DELIM + r\"relieved\" + DELIM: \"😌\",\n    DELIM + r\"sleepy\" + DELIM: \"😪\",\n    DELIM + r\"sleeping\" + DELIM: \"😴\",\n    DELIM + r\"pensive\" + DELIM: \"😔\",\n    DELIM + r\"drooling_face\" + DELIM: \"🤤\",\n    #\n    # Face Unwell\n    #\n    DELIM + r\"mask\" + DELIM: \"😷\",\n    DELIM + r\"face_with_head_bandage\" + DELIM: \"🤕\",\n    DELIM + r\"vomiting_face\" + DELIM: \"🤮\",\n    DELIM + r\"hot_face\" + DELIM: \"🥵\",\n    DELIM + r\"woozy_face\" + DELIM: \"🥴\",\n    DELIM + r\"face_with_spiral_eyes\" + DELIM: \"😵‍💫\",\n    DELIM + r\"face_with_thermometer\" + DELIM: \"🤒\",\n    DELIM + r\"nauseated_face\" + DELIM: \"🤢\",\n    DELIM + r\"sneezing_face\" + DELIM: \"🤧\",\n    DELIM + r\"cold_face\" + DELIM: \"🥶\",\n    DELIM + r\"dizzy_face\" + DELIM: \"😵\",\n    DELIM + r\"exploding_head\" + DELIM: \"🤯\",\n    #\n    # Face Hat\n    #\n    DELIM + r\"cowboy_hat_face\" + DELIM: \"🤠\",\n    DELIM + r\"disguised_face\" + DELIM: \"🥸\",\n    DELIM + r\"partying_face\" + DELIM: \"🥳\",\n    #\n    # Face Glasses\n    #\n    DELIM + r\"sunglasses\" + DELIM: \"😎\",\n    DELIM + r\"monocle_face\" + DELIM: \"🧐\",\n    DELIM + r\"nerd_face\" + DELIM: \"🤓\",\n    #\n    # Face Concerned\n    #\n    DELIM + r\"confused\" + DELIM: \"😕\",\n    DELIM + r\"slightly_frowning_face\" + DELIM: \"🙁\",\n    DELIM + r\"open_mouth\" + DELIM: \"😮\",\n    DELIM + r\"astonished\" + DELIM: \"😲\",\n    DELIM + r\"pleading_face\" + DELIM: \"🥺\",\n    DELIM + r\"anguished\" + DELIM: \"😧\",\n    DELIM + r\"cold_sweat\" + DELIM: \"😰\",\n    DELIM + r\"cry\" + DELIM: \"😢\",\n    DELIM + r\"scream\" + DELIM: \"😱\",\n    DELIM + r\"persevere\" + DELIM: \"😣\",\n    DELIM + r\"sweat\" + DELIM: \"😓\",\n    DELIM + r\"tired_face\" + DELIM: \"😫\",\n    DELIM + r\"worried\" + DELIM: \"😟\",\n    DELIM + r\"frowning_face\" + DELIM: \"☹️\",\n    DELIM + r\"hushed\" + DELIM: \"😯\",\n    DELIM + r\"flushed\" + DELIM: \"😳\",\n    DELIM + r\"frowning\" + DELIM: \"😦\",\n    DELIM + r\"fearful\" + DELIM: \"😨\",\n    DELIM + r\"disappointed_relieved\" + DELIM: \"😥\",\n    DELIM + r\"sob\" + DELIM: \"😭\",\n    DELIM + r\"confounded\" + DELIM: \"😖\",\n    DELIM + r\"disappointed\" + DELIM: \"😞\",\n    DELIM + r\"weary\" + DELIM: \"😩\",\n    DELIM + r\"yawning_face\" + DELIM: \"🥱\",\n    #\n    # Face Negative\n    #\n    DELIM + r\"triumph\" + DELIM: \"😤\",\n    DELIM + r\"angry\" + DELIM: \"😠\",\n    DELIM + r\"smiling_imp\" + DELIM: \"😈\",\n    DELIM + r\"skull\" + DELIM: \"💀\",\n    DELIM + r\"(pout|rage)\" + DELIM: \"😡\",\n    DELIM + r\"cursing_face\" + DELIM: \"🤬\",\n    DELIM + r\"imp\" + DELIM: \"👿\",\n    DELIM + r\"skull_and_crossbones\" + DELIM: \"☠️\",\n    #\n    # Face Costume\n    #\n    DELIM + r\"(hankey|poop|shit)\" + DELIM: \"💩\",\n    DELIM + r\"japanese_ogre\" + DELIM: \"👹\",\n    DELIM + r\"ghost\" + DELIM: \"👻\",\n    DELIM + r\"space_invader\" + DELIM: \"👾\",\n    DELIM + r\"clown_face\" + DELIM: \"🤡\",\n    DELIM + r\"japanese_goblin\" + DELIM: \"👺\",\n    DELIM + r\"alien\" + DELIM: \"👽\",\n    DELIM + r\"robot\" + DELIM: \"🤖\",\n    #\n    # Cat Face\n    #\n    DELIM + r\"smiley_cat\" + DELIM: \"😺\",\n    DELIM + r\"joy_cat\" + DELIM: \"😹\",\n    DELIM + r\"smirk_cat\" + DELIM: \"😼\",\n    DELIM + r\"scream_cat\" + DELIM: \"🙀\",\n    DELIM + r\"pouting_cat\" + DELIM: \"😾\",\n    DELIM + r\"smile_cat\" + DELIM: \"😸\",\n    DELIM + r\"heart_eyes_cat\" + DELIM: \"😻\",\n    DELIM + r\"kissing_cat\" + DELIM: \"😽\",\n    DELIM + r\"crying_cat_face\" + DELIM: \"😿\",\n    #\n    # Monkey Face\n    #\n    DELIM + r\"see_no_evil\" + DELIM: \"🙈\",\n    DELIM + r\"speak_no_evil\" + DELIM: \"🙊\",\n    DELIM + r\"hear_no_evil\" + DELIM: \"🙉\",\n    #\n    # Heart\n    #\n    DELIM + r\"love_letter\" + DELIM: \"💌\",\n    DELIM + r\"gift_heart\" + DELIM: \"💝\",\n    DELIM + r\"heartpulse\" + DELIM: \"💗\",\n    DELIM + r\"revolving_hearts\" + DELIM: \"💞\",\n    DELIM + r\"heart_decoration\" + DELIM: \"💟\",\n    DELIM + r\"broken_heart\" + DELIM: \"💔\",\n    DELIM + r\"mending_heart\" + DELIM: \"❤️‍🩹\",\n    DELIM + r\"orange_heart\" + DELIM: \"🧡\",\n    DELIM + r\"green_heart\" + DELIM: \"💚\",\n    DELIM + r\"purple_heart\" + DELIM: \"💜\",\n    DELIM + r\"black_heart\" + DELIM: \"🖤\",\n    DELIM + r\"cupid\" + DELIM: \"💘\",\n    DELIM + r\"sparkling_heart\" + DELIM: \"💖\",\n    DELIM + r\"heartbeat\" + DELIM: \"💓\",\n    DELIM + r\"two_hearts\" + DELIM: \"💕\",\n    DELIM + r\"heavy_heart_exclamation\" + DELIM: \"❣️\",\n    DELIM + r\"heart_on_fire\" + DELIM: \"❤️‍🔥\",\n    DELIM + r\"heart\" + DELIM: \"❤️\",\n    DELIM + r\"yellow_heart\" + DELIM: \"💛\",\n    DELIM + r\"blue_heart\" + DELIM: \"💙\",\n    DELIM + r\"brown_heart\" + DELIM: \"🤎\",\n    DELIM + r\"white_heart\" + DELIM: \"🤍\",\n    #\n    # Emotion\n    #\n    DELIM + r\"kiss\" + DELIM: \"💋\",\n    DELIM + r\"anger\" + DELIM: \"💢\",\n    DELIM + r\"dizzy\" + DELIM: \"💫\",\n    DELIM + r\"dash\" + DELIM: \"💨\",\n    DELIM + r\"speech_balloon\" + DELIM: \"💬\",\n    DELIM + r\"left_speech_bubble\" + DELIM: \"🗨️\",\n    DELIM + r\"thought_balloon\" + DELIM: \"💭\",\n    DELIM + r\"100\" + DELIM: \"💯\",\n    DELIM + r\"(boom|collision)\" + DELIM: \"💥\",\n    DELIM + r\"sweat_drops\" + DELIM: \"💦\",\n    DELIM + r\"hole\" + DELIM: \"🕳️\",\n    DELIM + r\"eye_speech_bubble\" + DELIM: \"👁️‍🗨️\",\n    DELIM + r\"right_anger_bubble\" + DELIM: \"🗯️\",\n    DELIM + r\"zzz\" + DELIM: \"💤\",\n    #\n    # Hand Fingers Open\n    #\n    DELIM + r\"wave\" + DELIM: \"👋\",\n    DELIM + r\"raised_hand_with_fingers_splayed\" + DELIM: \"🖐️\",\n    DELIM + r\"vulcan_salute\" + DELIM: \"🖖\",\n    DELIM + r\"raised_back_of_hand\" + DELIM: \"🤚\",\n    DELIM + r\"(raised_)?hand\" + DELIM: \"✋\",\n    #\n    # Hand Fingers Partial\n    #\n    DELIM + r\"ok_hand\" + DELIM: \"👌\",\n    DELIM + r\"pinched_fingers\" + DELIM: \"🤌\",\n    DELIM + r\"pinching_hand\" + DELIM: \"🤏\",\n    DELIM + r\"v\" + DELIM: \"✌️\",\n    DELIM + r\"crossed_fingers\" + DELIM: \"🤞\",\n    DELIM + r\"love_you_gesture\" + DELIM: \"🤟\",\n    DELIM + r\"metal\" + DELIM: \"🤘\",\n    DELIM + r\"call_me_hand\" + DELIM: \"🤙\",\n    #\n    # Hand Single Finger\n    #\n    DELIM + r\"point_left\" + DELIM: \"👈\",\n    DELIM + r\"point_right\" + DELIM: \"👉\",\n    DELIM + r\"point_up_2\" + DELIM: \"👆\",\n    DELIM + r\"(fu|middle_finger)\" + DELIM: \"🖕\",\n    DELIM + r\"point_down\" + DELIM: \"👇\",\n    DELIM + r\"point_up\" + DELIM: \"☝️\",\n    #\n    # Hand Fingers Closed\n    #\n    DELIM + r\"(\\+1|thumbsup)\" + DELIM: \"👍\",\n    DELIM + r\"(-1|thumbsdown)\" + DELIM: \"👎\",\n    DELIM + r\"fist\" + DELIM: \"✊\",\n    DELIM + r\"(fist_(raised|oncoming)|(face)?punch)\" + DELIM: \"👊\",\n    DELIM + r\"fist_left\" + DELIM: \"🤛\",\n    DELIM + r\"fist_right\" + DELIM: \"🤜\",\n    #\n    # Hands\n    #\n    DELIM + r\"clap\" + DELIM: \"👏\",\n    DELIM + r\"raised_hands\" + DELIM: \"🙌\",\n    DELIM + r\"open_hands\" + DELIM: \"👐\",\n    DELIM + r\"palms_up_together\" + DELIM: \"🤲\",\n    DELIM + r\"handshake\" + DELIM: \"🤝\",\n    DELIM + r\"pray\" + DELIM: \"🙏\",\n    #\n    # Hand Prop\n    #\n    DELIM + r\"writing_hand\" + DELIM: \"✍️\",\n    DELIM + r\"nail_care\" + DELIM: \"💅\",\n    DELIM + r\"selfie\" + DELIM: \"🤳\",\n    #\n    # Body Parts\n    #\n    DELIM + r\"muscle\" + DELIM: \"💪\",\n    DELIM + r\"mechanical_arm\" + DELIM: \"🦾\",\n    DELIM + r\"mechanical_leg\" + DELIM: \"🦿\",\n    DELIM + r\"leg\" + DELIM: \"🦵\",\n    DELIM + r\"foot\" + DELIM: \"🦶\",\n    DELIM + r\"ear\" + DELIM: \"👂\",\n    DELIM + r\"ear_with_hearing_aid\" + DELIM: \"🦻\",\n    DELIM + r\"nose\" + DELIM: \"👃\",\n    DELIM + r\"brain\" + DELIM: \"🧠\",\n    DELIM + r\"anatomical_heart\" + DELIM: \"🫀\",\n    DELIM + r\"lungs\" + DELIM: \"🫁\",\n    DELIM + r\"tooth\" + DELIM: \"🦷\",\n    DELIM + r\"bone\" + DELIM: \"🦴\",\n    DELIM + r\"eyes\" + DELIM: \"👀\",\n    DELIM + r\"eye\" + DELIM: \"👁️\",\n    DELIM + r\"tongue\" + DELIM: \"👅\",\n    DELIM + r\"lips\" + DELIM: \"👄\",\n    #\n    # Person\n    #\n    DELIM + r\"baby\" + DELIM: \"👶\",\n    DELIM + r\"child\" + DELIM: \"🧒\",\n    DELIM + r\"boy\" + DELIM: \"👦\",\n    DELIM + r\"girl\" + DELIM: \"👧\",\n    DELIM + r\"adult\" + DELIM: \"🧑\",\n    DELIM + r\"blond_haired_person\" + DELIM: \"👱\",\n    DELIM + r\"man\" + DELIM: \"👨\",\n    DELIM + r\"bearded_person\" + DELIM: \"🧔\",\n    DELIM + r\"man_beard\" + DELIM: \"🧔‍♂️\",\n    DELIM + r\"woman_beard\" + DELIM: \"🧔‍♀️\",\n    DELIM + r\"red_haired_man\" + DELIM: \"👨‍🦰\",\n    DELIM + r\"curly_haired_man\" + DELIM: \"👨‍🦱\",\n    DELIM + r\"white_haired_man\" + DELIM: \"👨‍🦳\",\n    DELIM + r\"bald_man\" + DELIM: \"👨‍🦲\",\n    DELIM + r\"woman\" + DELIM: \"👩\",\n    DELIM + r\"red_haired_woman\" + DELIM: \"👩‍🦰\",\n    DELIM + r\"person_red_hair\" + DELIM: \"🧑‍🦰\",\n    DELIM + r\"curly_haired_woman\" + DELIM: \"👩‍🦱\",\n    DELIM + r\"person_curly_hair\" + DELIM: \"🧑‍🦱\",\n    DELIM + r\"white_haired_woman\" + DELIM: \"👩‍🦳\",\n    DELIM + r\"person_white_hair\" + DELIM: \"🧑‍🦳\",\n    DELIM + r\"bald_woman\" + DELIM: \"👩‍🦲\",\n    DELIM + r\"person_bald\" + DELIM: \"🧑‍🦲\",\n    DELIM + r\"blond_(haired_)?woman\" + DELIM: \"👱‍♀️\",\n    DELIM + r\"blond_haired_man\" + DELIM: \"👱‍♂️\",\n    DELIM + r\"older_adult\" + DELIM: \"🧓\",\n    DELIM + r\"older_man\" + DELIM: \"👴\",\n    DELIM + r\"older_woman\" + DELIM: \"👵\",\n    #\n    # Person Gesture\n    #\n    DELIM + r\"frowning_person\" + DELIM: \"🙍\",\n    DELIM + r\"frowning_man\" + DELIM: \"🙍‍♂️\",\n    DELIM + r\"frowning_woman\" + DELIM: \"🙍‍♀️\",\n    DELIM + r\"pouting_face\" + DELIM: \"🙎\",\n    DELIM + r\"pouting_man\" + DELIM: \"🙎‍♂️\",\n    DELIM + r\"pouting_woman\" + DELIM: \"🙎‍♀️\",\n    DELIM + r\"no_good\" + DELIM: \"🙅\",\n    DELIM + r\"(ng|no_good)_man\" + DELIM: \"🙅‍♂️\",\n    DELIM + r\"(ng_woman|no_good_woman)\" + DELIM: \"🙅‍♀️\",\n    DELIM + r\"ok_person\" + DELIM: \"🙆\",\n    DELIM + r\"ok_man\" + DELIM: \"🙆‍♂️\",\n    DELIM + r\"ok_woman\" + DELIM: \"🙆‍♀️\",\n    DELIM + r\"(information_desk|tipping_hand_)person\" + DELIM: \"💁\",\n    DELIM + r\"(sassy_man|tipping_hand_man)\" + DELIM: \"💁‍♂️\",\n    DELIM + r\"(sassy_woman|tipping_hand_woman)\" + DELIM: \"💁‍♀️\",\n    DELIM + r\"raising_hand\" + DELIM: \"🙋\",\n    DELIM + r\"raising_hand_man\" + DELIM: \"🙋‍♂️\",\n    DELIM + r\"raising_hand_woman\" + DELIM: \"🙋‍♀️\",\n    DELIM + r\"deaf_person\" + DELIM: \"🧏\",\n    DELIM + r\"deaf_man\" + DELIM: \"🧏‍♂️\",\n    DELIM + r\"deaf_woman\" + DELIM: \"🧏‍♀️\",\n    DELIM + r\"bow\" + DELIM: \"🙇\",\n    DELIM + r\"bowing_man\" + DELIM: \"🙇‍♂️\",\n    DELIM + r\"bowing_woman\" + DELIM: \"🙇‍♀️\",\n    DELIM + r\"facepalm\" + DELIM: \"🤦\",\n    DELIM + r\"man_facepalming\" + DELIM: \"🤦‍♂️\",\n    DELIM + r\"woman_facepalming\" + DELIM: \"🤦‍♀️\",\n    DELIM + r\"shrug\" + DELIM: \"🤷\",\n    DELIM + r\"man_shrugging\" + DELIM: \"🤷‍♂️\",\n    DELIM + r\"woman_shrugging\" + DELIM: \"🤷‍♀️\",\n    #\n    # Person Role\n    #\n    DELIM + r\"health_worker\" + DELIM: \"🧑‍⚕️\",\n    DELIM + r\"man_health_worker\" + DELIM: \"👨‍⚕️\",\n    DELIM + r\"woman_health_worker\" + DELIM: \"👩‍⚕️\",\n    DELIM + r\"student\" + DELIM: \"🧑‍🎓\",\n    DELIM + r\"man_student\" + DELIM: \"👨‍🎓\",\n    DELIM + r\"woman_student\" + DELIM: \"👩‍🎓\",\n    DELIM + r\"teacher\" + DELIM: \"🧑‍🏫\",\n    DELIM + r\"man_teacher\" + DELIM: \"👨‍🏫\",\n    DELIM + r\"woman_teacher\" + DELIM: \"👩‍🏫\",\n    DELIM + r\"judge\" + DELIM: \"🧑‍⚖️\",\n    DELIM + r\"man_judge\" + DELIM: \"👨‍⚖️\",\n    DELIM + r\"woman_judge\" + DELIM: \"👩‍⚖️\",\n    DELIM + r\"farmer\" + DELIM: \"🧑‍🌾\",\n    DELIM + r\"man_farmer\" + DELIM: \"👨‍🌾\",\n    DELIM + r\"woman_farmer\" + DELIM: \"👩‍🌾\",\n    DELIM + r\"cook\" + DELIM: \"🧑‍🍳\",\n    DELIM + r\"man_cook\" + DELIM: \"👨‍🍳\",\n    DELIM + r\"woman_cook\" + DELIM: \"👩‍🍳\",\n    DELIM + r\"mechanic\" + DELIM: \"🧑‍🔧\",\n    DELIM + r\"man_mechanic\" + DELIM: \"👨‍🔧\",\n    DELIM + r\"woman_mechanic\" + DELIM: \"👩‍🔧\",\n    DELIM + r\"factory_worker\" + DELIM: \"🧑‍🏭\",\n    DELIM + r\"man_factory_worker\" + DELIM: \"👨‍🏭\",\n    DELIM + r\"woman_factory_worker\" + DELIM: \"👩‍🏭\",\n    DELIM + r\"office_worker\" + DELIM: \"🧑‍💼\",\n    DELIM + r\"man_office_worker\" + DELIM: \"👨‍💼\",\n    DELIM + r\"woman_office_worker\" + DELIM: \"👩‍💼\",\n    DELIM + r\"scientist\" + DELIM: \"🧑‍🔬\",\n    DELIM + r\"man_scientist\" + DELIM: \"👨‍🔬\",\n    DELIM + r\"woman_scientist\" + DELIM: \"👩‍🔬\",\n    DELIM + r\"technologist\" + DELIM: \"🧑‍💻\",\n    DELIM + r\"man_technologist\" + DELIM: \"👨‍💻\",\n    DELIM + r\"woman_technologist\" + DELIM: \"👩‍💻\",\n    DELIM + r\"singer\" + DELIM: \"🧑‍🎤\",\n    DELIM + r\"man_singer\" + DELIM: \"👨‍🎤\",\n    DELIM + r\"woman_singer\" + DELIM: \"👩‍🎤\",\n    DELIM + r\"artist\" + DELIM: \"🧑‍🎨\",\n    DELIM + r\"man_artist\" + DELIM: \"👨‍🎨\",\n    DELIM + r\"woman_artist\" + DELIM: \"👩‍🎨\",\n    DELIM + r\"pilot\" + DELIM: \"🧑‍✈️\",\n    DELIM + r\"man_pilot\" + DELIM: \"👨‍✈️\",\n    DELIM + r\"woman_pilot\" + DELIM: \"👩‍✈️\",\n    DELIM + r\"astronaut\" + DELIM: \"🧑‍🚀\",\n    DELIM + r\"man_astronaut\" + DELIM: \"👨‍🚀\",\n    DELIM + r\"woman_astronaut\" + DELIM: \"👩‍🚀\",\n    DELIM + r\"firefighter\" + DELIM: \"🧑‍🚒\",\n    DELIM + r\"man_firefighter\" + DELIM: \"👨‍🚒\",\n    DELIM + r\"woman_firefighter\" + DELIM: \"👩‍🚒\",\n    DELIM + r\"cop\" + DELIM: \"👮\",\n    DELIM + r\"police(_officer|man)\" + DELIM: \"👮‍♂️\",\n    DELIM + r\"policewoman\" + DELIM: \"👮‍♀️\",\n    DELIM + r\"detective\" + DELIM: \"🕵️\",\n    DELIM + r\"male_detective\" + DELIM: \"🕵️‍♂️\",\n    DELIM + r\"female_detective\" + DELIM: \"🕵️‍♀️\",\n    DELIM + r\"guard\" + DELIM: \"💂\",\n    DELIM + r\"guardsman\" + DELIM: \"💂‍♂️\",\n    DELIM + r\"guardswoman\" + DELIM: \"💂‍♀️\",\n    DELIM + r\"ninja\" + DELIM: \"🥷\",\n    DELIM + r\"construction_worker\" + DELIM: \"👷\",\n    DELIM + r\"construction_worker_man\" + DELIM: \"👷‍♂️\",\n    DELIM + r\"construction_worker_woman\" + DELIM: \"👷‍♀️\",\n    DELIM + r\"prince\" + DELIM: \"🤴\",\n    DELIM + r\"princess\" + DELIM: \"👸\",\n    DELIM + r\"person_with_turban\" + DELIM: \"👳\",\n    DELIM + r\"man_with_turban\" + DELIM: \"👳‍♂️\",\n    DELIM + r\"woman_with_turban\" + DELIM: \"👳‍♀️\",\n    DELIM + r\"man_with_gua_pi_mao\" + DELIM: \"👲\",\n    DELIM + r\"woman_with_headscarf\" + DELIM: \"🧕\",\n    DELIM + r\"person_in_tuxedo\" + DELIM: \"🤵\",\n    DELIM + r\"man_in_tuxedo\" + DELIM: \"🤵‍♂️\",\n    DELIM + r\"woman_in_tuxedo\" + DELIM: \"🤵‍♀️\",\n    DELIM + r\"person_with_veil\" + DELIM: \"👰\",\n    DELIM + r\"man_with_veil\" + DELIM: \"👰‍♂️\",\n    DELIM + r\"(bride|woman)_with_veil\" + DELIM: \"👰‍♀️\",\n    DELIM + r\"pregnant_woman\" + DELIM: \"🤰\",\n    DELIM + r\"breast_feeding\" + DELIM: \"🤱\",\n    DELIM + r\"woman_feeding_baby\" + DELIM: \"👩‍🍼\",\n    DELIM + r\"man_feeding_baby\" + DELIM: \"👨‍🍼\",\n    DELIM + r\"person_feeding_baby\" + DELIM: \"🧑‍🍼\",\n    #\n    # Person Fantasy\n    #\n    DELIM + r\"angel\" + DELIM: \"👼\",\n    DELIM + r\"santa\" + DELIM: \"🎅\",\n    DELIM + r\"mrs_claus\" + DELIM: \"🤶\",\n    DELIM + r\"mx_claus\" + DELIM: \"🧑‍🎄\",\n    DELIM + r\"superhero\" + DELIM: \"🦸\",\n    DELIM + r\"superhero_man\" + DELIM: \"🦸‍♂️\",\n    DELIM + r\"superhero_woman\" + DELIM: \"🦸‍♀️\",\n    DELIM + r\"supervillain\" + DELIM: \"🦹\",\n    DELIM + r\"supervillain_man\" + DELIM: \"🦹‍♂️\",\n    DELIM + r\"supervillain_woman\" + DELIM: \"🦹‍♀️\",\n    DELIM + r\"mage\" + DELIM: \"🧙\",\n    DELIM + r\"mage_man\" + DELIM: \"🧙‍♂️\",\n    DELIM + r\"mage_woman\" + DELIM: \"🧙‍♀️\",\n    DELIM + r\"fairy\" + DELIM: \"🧚\",\n    DELIM + r\"fairy_man\" + DELIM: \"🧚‍♂️\",\n    DELIM + r\"fairy_woman\" + DELIM: \"🧚‍♀️\",\n    DELIM + r\"vampire\" + DELIM: \"🧛\",\n    DELIM + r\"vampire_man\" + DELIM: \"🧛‍♂️\",\n    DELIM + r\"vampire_woman\" + DELIM: \"🧛‍♀️\",\n    DELIM + r\"merperson\" + DELIM: \"🧜\",\n    DELIM + r\"merman\" + DELIM: \"🧜‍♂️\",\n    DELIM + r\"mermaid\" + DELIM: \"🧜‍♀️\",\n    DELIM + r\"elf\" + DELIM: \"🧝\",\n    DELIM + r\"elf_man\" + DELIM: \"🧝‍♂️\",\n    DELIM + r\"elf_woman\" + DELIM: \"🧝‍♀️\",\n    DELIM + r\"genie\" + DELIM: \"🧞\",\n    DELIM + r\"genie_man\" + DELIM: \"🧞‍♂️\",\n    DELIM + r\"genie_woman\" + DELIM: \"🧞‍♀️\",\n    DELIM + r\"zombie\" + DELIM: \"🧟\",\n    DELIM + r\"zombie_man\" + DELIM: \"🧟‍♂️\",\n    DELIM + r\"zombie_woman\" + DELIM: \"🧟‍♀️\",\n    #\n    # Person Activity\n    #\n    DELIM + r\"massage\" + DELIM: \"💆\",\n    DELIM + r\"massage_man\" + DELIM: \"💆‍♂️\",\n    DELIM + r\"massage_woman\" + DELIM: \"💆‍♀️\",\n    DELIM + r\"haircut\" + DELIM: \"💇\",\n    DELIM + r\"haircut_man\" + DELIM: \"💇‍♂️\",\n    DELIM + r\"haircut_woman\" + DELIM: \"💇‍♀️\",\n    DELIM + r\"walking\" + DELIM: \"🚶\",\n    DELIM + r\"walking_man\" + DELIM: \"🚶‍♂️\",\n    DELIM + r\"walking_woman\" + DELIM: \"🚶‍♀️\",\n    DELIM + r\"standing_person\" + DELIM: \"🧍\",\n    DELIM + r\"standing_man\" + DELIM: \"🧍‍♂️\",\n    DELIM + r\"standing_woman\" + DELIM: \"🧍‍♀️\",\n    DELIM + r\"kneeling_person\" + DELIM: \"🧎\",\n    DELIM + r\"kneeling_man\" + DELIM: \"🧎‍♂️\",\n    DELIM + r\"kneeling_woman\" + DELIM: \"🧎‍♀️\",\n    DELIM + r\"person_with_probing_cane\" + DELIM: \"🧑‍🦯\",\n    DELIM + r\"man_with_probing_cane\" + DELIM: \"👨‍🦯\",\n    DELIM + r\"woman_with_probing_cane\" + DELIM: \"👩‍🦯\",\n    DELIM + r\"person_in_motorized_wheelchair\" + DELIM: \"🧑‍🦼\",\n    DELIM + r\"man_in_motorized_wheelchair\" + DELIM: \"👨‍🦼\",\n    DELIM + r\"woman_in_motorized_wheelchair\" + DELIM: \"👩‍🦼\",\n    DELIM + r\"person_in_manual_wheelchair\" + DELIM: \"🧑‍🦽\",\n    DELIM + r\"man_in_manual_wheelchair\" + DELIM: \"👨‍🦽\",\n    DELIM + r\"woman_in_manual_wheelchair\" + DELIM: \"👩‍🦽\",\n    DELIM + r\"runn(er|ing)\" + DELIM: \"🏃\",\n    DELIM + r\"running_man\" + DELIM: \"🏃‍♂️\",\n    DELIM + r\"running_woman\" + DELIM: \"🏃‍♀️\",\n    DELIM + r\"(dancer|woman_dancing)\" + DELIM: \"💃\",\n    DELIM + r\"man_dancing\" + DELIM: \"🕺\",\n    DELIM + r\"business_suit_levitating\" + DELIM: \"🕴️\",\n    DELIM + r\"dancers\" + DELIM: \"👯\",\n    DELIM + r\"dancing_men\" + DELIM: \"👯‍♂️\",\n    DELIM + r\"dancing_women\" + DELIM: \"👯‍♀️\",\n    DELIM + r\"sauna_person\" + DELIM: \"🧖\",\n    DELIM + r\"sauna_man\" + DELIM: \"🧖‍♂️\",\n    DELIM + r\"sauna_woman\" + DELIM: \"🧖‍♀️\",\n    DELIM + r\"climbing\" + DELIM: \"🧗\",\n    DELIM + r\"climbing_man\" + DELIM: \"🧗‍♂️\",\n    DELIM + r\"climbing_woman\" + DELIM: \"🧗‍♀️\",\n    #\n    # Person Sport\n    #\n    DELIM + r\"person_fencing\" + DELIM: \"🤺\",\n    DELIM + r\"horse_racing\" + DELIM: \"🏇\",\n    DELIM + r\"skier\" + DELIM: \"⛷️\",\n    DELIM + r\"snowboarder\" + DELIM: \"🏂\",\n    DELIM + r\"golfing\" + DELIM: \"🏌️\",\n    DELIM + r\"golfing_man\" + DELIM: \"🏌️‍♂️\",\n    DELIM + r\"golfing_woman\" + DELIM: \"🏌️‍♀️\",\n    DELIM + r\"surfer\" + DELIM: \"🏄\",\n    DELIM + r\"surfing_man\" + DELIM: \"🏄‍♂️\",\n    DELIM + r\"surfing_woman\" + DELIM: \"🏄‍♀️\",\n    DELIM + r\"rowboat\" + DELIM: \"🚣\",\n    DELIM + r\"rowing_man\" + DELIM: \"🚣‍♂️\",\n    DELIM + r\"rowing_woman\" + DELIM: \"🚣‍♀️\",\n    DELIM + r\"swimmer\" + DELIM: \"🏊\",\n    DELIM + r\"swimming_man\" + DELIM: \"🏊‍♂️\",\n    DELIM + r\"swimming_woman\" + DELIM: \"🏊‍♀️\",\n    DELIM + r\"bouncing_ball_person\" + DELIM: \"⛹️\",\n    DELIM + r\"(basketball|bouncing_ball)_man\" + DELIM: \"⛹️‍♂️\",\n    DELIM + r\"(basketball|bouncing_ball)_woman\" + DELIM: \"⛹️‍♀️\",\n    DELIM + r\"weight_lifting\" + DELIM: \"🏋️\",\n    DELIM + r\"weight_lifting_man\" + DELIM: \"🏋️‍♂️\",\n    DELIM + r\"weight_lifting_woman\" + DELIM: \"🏋️‍♀️\",\n    DELIM + r\"bicyclist\" + DELIM: \"🚴\",\n    DELIM + r\"biking_man\" + DELIM: \"🚴‍♂️\",\n    DELIM + r\"biking_woman\" + DELIM: \"🚴‍♀️\",\n    DELIM + r\"mountain_bicyclist\" + DELIM: \"🚵\",\n    DELIM + r\"mountain_biking_man\" + DELIM: \"🚵‍♂️\",\n    DELIM + r\"mountain_biking_woman\" + DELIM: \"🚵‍♀️\",\n    DELIM + r\"cartwheeling\" + DELIM: \"🤸\",\n    DELIM + r\"man_cartwheeling\" + DELIM: \"🤸‍♂️\",\n    DELIM + r\"woman_cartwheeling\" + DELIM: \"🤸‍♀️\",\n    DELIM + r\"wrestling\" + DELIM: \"🤼\",\n    DELIM + r\"men_wrestling\" + DELIM: \"🤼‍♂️\",\n    DELIM + r\"women_wrestling\" + DELIM: \"🤼‍♀️\",\n    DELIM + r\"water_polo\" + DELIM: \"🤽\",\n    DELIM + r\"man_playing_water_polo\" + DELIM: \"🤽‍♂️\",\n    DELIM + r\"woman_playing_water_polo\" + DELIM: \"🤽‍♀️\",\n    DELIM + r\"handball_person\" + DELIM: \"🤾\",\n    DELIM + r\"man_playing_handball\" + DELIM: \"🤾‍♂️\",\n    DELIM + r\"woman_playing_handball\" + DELIM: \"🤾‍♀️\",\n    DELIM + r\"juggling_person\" + DELIM: \"🤹\",\n    DELIM + r\"man_juggling\" + DELIM: \"🤹‍♂️\",\n    DELIM + r\"woman_juggling\" + DELIM: \"🤹‍♀️\",\n    #\n    # Person Resting\n    #\n    DELIM + r\"lotus_position\" + DELIM: \"🧘\",\n    DELIM + r\"lotus_position_man\" + DELIM: \"🧘‍♂️\",\n    DELIM + r\"lotus_position_woman\" + DELIM: \"🧘‍♀️\",\n    DELIM + r\"bath\" + DELIM: \"🛀\",\n    DELIM + r\"sleeping_bed\" + DELIM: \"🛌\",\n    #\n    # Family\n    #\n    DELIM + r\"people_holding_hands\" + DELIM: \"🧑‍🤝‍🧑\",\n    DELIM + r\"two_women_holding_hands\" + DELIM: \"👭\",\n    DELIM + r\"couple\" + DELIM: \"👫\",\n    DELIM + r\"two_men_holding_hands\" + DELIM: \"👬\",\n    DELIM + r\"couplekiss\" + DELIM: \"💏\",\n    DELIM + r\"couplekiss_man_woman\" + DELIM: \"👩‍❤️‍💋‍👨\",\n    DELIM + r\"couplekiss_man_man\" + DELIM: \"👨‍❤️‍💋‍👨\",\n    DELIM + r\"couplekiss_woman_woman\" + DELIM: \"👩‍❤️‍💋‍👩\",\n    DELIM + r\"couple_with_heart\" + DELIM: \"💑\",\n    DELIM + r\"couple_with_heart_woman_man\" + DELIM: \"👩‍❤️‍👨\",\n    DELIM + r\"couple_with_heart_man_man\" + DELIM: \"👨‍❤️‍👨\",\n    DELIM + r\"couple_with_heart_woman_woman\" + DELIM: \"👩‍❤️‍👩\",\n    DELIM + r\"family_man_woman_boy\" + DELIM: \"👨‍👩‍👦\",\n    DELIM + r\"family_man_woman_girl\" + DELIM: \"👨‍👩‍👧\",\n    DELIM + r\"family_man_woman_girl_boy\" + DELIM: \"👨‍👩‍👧‍👦\",\n    DELIM + r\"family_man_woman_boy_boy\" + DELIM: \"👨‍👩‍👦‍👦\",\n    DELIM + r\"family_man_woman_girl_girl\" + DELIM: \"👨‍👩‍👧‍👧\",\n    DELIM + r\"family_man_man_boy\" + DELIM: \"👨‍👨‍👦\",\n    DELIM + r\"family_man_man_girl\" + DELIM: \"👨‍👨‍👧\",\n    DELIM + r\"family_man_man_girl_boy\" + DELIM: \"👨‍👨‍👧‍👦\",\n    DELIM + r\"family_man_man_boy_boy\" + DELIM: \"👨‍👨‍👦‍👦\",\n    DELIM + r\"family_man_man_girl_girl\" + DELIM: \"👨‍👨‍👧‍👧\",\n    DELIM + r\"family_woman_woman_boy\" + DELIM: \"👩‍👩‍👦\",\n    DELIM + r\"family_woman_woman_girl\" + DELIM: \"👩‍👩‍👧\",\n    DELIM + r\"family_woman_woman_girl_boy\" + DELIM: \"👩‍👩‍👧‍👦\",\n    DELIM + r\"family_woman_woman_boy_boy\" + DELIM: \"👩‍👩‍👦‍👦\",\n    DELIM + r\"family_woman_woman_girl_girl\" + DELIM: \"👩‍👩‍👧‍👧\",\n    DELIM + r\"family_man_boy\" + DELIM: \"👨‍👦\",\n    DELIM + r\"family_man_boy_boy\" + DELIM: \"👨‍👦‍👦\",\n    DELIM + r\"family_man_girl\" + DELIM: \"👨‍👧\",\n    DELIM + r\"family_man_girl_boy\" + DELIM: \"👨‍👧‍👦\",\n    DELIM + r\"family_man_girl_girl\" + DELIM: \"👨‍👧‍👧\",\n    DELIM + r\"family_woman_boy\" + DELIM: \"👩‍👦\",\n    DELIM + r\"family_woman_boy_boy\" + DELIM: \"👩‍👦‍👦\",\n    DELIM + r\"family_woman_girl\" + DELIM: \"👩‍👧\",\n    DELIM + r\"family_woman_girl_boy\" + DELIM: \"👩‍👧‍👦\",\n    DELIM + r\"family_woman_girl_girl\" + DELIM: \"👩‍👧‍👧\",\n    #\n    # Person Symbol\n    #\n    DELIM + r\"speaking_head\" + DELIM: \"🗣️\",\n    DELIM + r\"bust_in_silhouette\" + DELIM: \"👤\",\n    DELIM + r\"busts_in_silhouette\" + DELIM: \"👥\",\n    DELIM + r\"people_hugging\" + DELIM: \"🫂\",\n    DELIM + r\"family\" + DELIM: \"👪\",\n    DELIM + r\"footprints\" + DELIM: \"👣\",\n    #\n    # Animal Mammal\n    #\n    DELIM + r\"monkey_face\" + DELIM: \"🐵\",\n    DELIM + r\"monkey\" + DELIM: \"🐒\",\n    DELIM + r\"gorilla\" + DELIM: \"🦍\",\n    DELIM + r\"orangutan\" + DELIM: \"🦧\",\n    DELIM + r\"dog\" + DELIM: \"🐶\",\n    DELIM + r\"dog2\" + DELIM: \"🐕\",\n    DELIM + r\"guide_dog\" + DELIM: \"🦮\",\n    DELIM + r\"service_dog\" + DELIM: \"🐕‍🦺\",\n    DELIM + r\"poodle\" + DELIM: \"🐩\",\n    DELIM + r\"wolf\" + DELIM: \"🐺\",\n    DELIM + r\"fox_face\" + DELIM: \"🦊\",\n    DELIM + r\"raccoon\" + DELIM: \"🦝\",\n    DELIM + r\"cat\" + DELIM: \"🐱\",\n    DELIM + r\"cat2\" + DELIM: \"🐈\",\n    DELIM + r\"black_cat\" + DELIM: \"🐈‍⬛\",\n    DELIM + r\"lion\" + DELIM: \"🦁\",\n    DELIM + r\"tiger\" + DELIM: \"🐯\",\n    DELIM + r\"tiger2\" + DELIM: \"🐅\",\n    DELIM + r\"leopard\" + DELIM: \"🐆\",\n    DELIM + r\"horse\" + DELIM: \"🐴\",\n    DELIM + r\"racehorse\" + DELIM: \"🐎\",\n    DELIM + r\"unicorn\" + DELIM: \"🦄\",\n    DELIM + r\"zebra\" + DELIM: \"🦓\",\n    DELIM + r\"deer\" + DELIM: \"🦌\",\n    DELIM + r\"bison\" + DELIM: \"🦬\",\n    DELIM + r\"cow\" + DELIM: \"🐮\",\n    DELIM + r\"ox\" + DELIM: \"🐂\",\n    DELIM + r\"water_buffalo\" + DELIM: \"🐃\",\n    DELIM + r\"cow2\" + DELIM: \"🐄\",\n    DELIM + r\"pig\" + DELIM: \"🐷\",\n    DELIM + r\"pig2\" + DELIM: \"🐖\",\n    DELIM + r\"boar\" + DELIM: \"🐗\",\n    DELIM + r\"pig_nose\" + DELIM: \"🐽\",\n    DELIM + r\"ram\" + DELIM: \"🐏\",\n    DELIM + r\"sheep\" + DELIM: \"🐑\",\n    DELIM + r\"goat\" + DELIM: \"🐐\",\n    DELIM + r\"dromedary_camel\" + DELIM: \"🐪\",\n    DELIM + r\"camel\" + DELIM: \"🐫\",\n    DELIM + r\"llama\" + DELIM: \"🦙\",\n    DELIM + r\"giraffe\" + DELIM: \"🦒\",\n    DELIM + r\"elephant\" + DELIM: \"🐘\",\n    DELIM + r\"mammoth\" + DELIM: \"🦣\",\n    DELIM + r\"rhinoceros\" + DELIM: \"🦏\",\n    DELIM + r\"hippopotamus\" + DELIM: \"🦛\",\n    DELIM + r\"mouse\" + DELIM: \"🐭\",\n    DELIM + r\"mouse2\" + DELIM: \"🐁\",\n    DELIM + r\"rat\" + DELIM: \"🐀\",\n    DELIM + r\"hamster\" + DELIM: \"🐹\",\n    DELIM + r\"rabbit\" + DELIM: \"🐰\",\n    DELIM + r\"rabbit2\" + DELIM: \"🐇\",\n    DELIM + r\"chipmunk\" + DELIM: \"🐿️\",\n    DELIM + r\"beaver\" + DELIM: \"🦫\",\n    DELIM + r\"hedgehog\" + DELIM: \"🦔\",\n    DELIM + r\"bat\" + DELIM: \"🦇\",\n    DELIM + r\"bear\" + DELIM: \"🐻\",\n    DELIM + r\"polar_bear\" + DELIM: \"🐻‍❄️\",\n    DELIM + r\"koala\" + DELIM: \"🐨\",\n    DELIM + r\"panda_face\" + DELIM: \"🐼\",\n    DELIM + r\"sloth\" + DELIM: \"🦥\",\n    DELIM + r\"otter\" + DELIM: \"🦦\",\n    DELIM + r\"skunk\" + DELIM: \"🦨\",\n    DELIM + r\"kangaroo\" + DELIM: \"🦘\",\n    DELIM + r\"badger\" + DELIM: \"🦡\",\n    DELIM + r\"(feet|paw_prints)\" + DELIM: \"🐾\",\n    #\n    # Animal Bird\n    #\n    DELIM + r\"turkey\" + DELIM: \"🦃\",\n    DELIM + r\"chicken\" + DELIM: \"🐔\",\n    DELIM + r\"rooster\" + DELIM: \"🐓\",\n    DELIM + r\"hatching_chick\" + DELIM: \"🐣\",\n    DELIM + r\"baby_chick\" + DELIM: \"🐤\",\n    DELIM + r\"hatched_chick\" + DELIM: \"🐥\",\n    DELIM + r\"bird\" + DELIM: \"🐦\",\n    DELIM + r\"penguin\" + DELIM: \"🐧\",\n    DELIM + r\"dove\" + DELIM: \"🕊️\",\n    DELIM + r\"eagle\" + DELIM: \"🦅\",\n    DELIM + r\"duck\" + DELIM: \"🦆\",\n    DELIM + r\"swan\" + DELIM: \"🦢\",\n    DELIM + r\"owl\" + DELIM: \"🦉\",\n    DELIM + r\"dodo\" + DELIM: \"🦤\",\n    DELIM + r\"feather\" + DELIM: \"🪶\",\n    DELIM + r\"flamingo\" + DELIM: \"🦩\",\n    DELIM + r\"peacock\" + DELIM: \"🦚\",\n    DELIM + r\"parrot\" + DELIM: \"🦜\",\n    #\n    # Animal Amphibian\n    #\n    DELIM + r\"frog\" + DELIM: \"🐸\",\n    #\n    # Animal Reptile\n    #\n    DELIM + r\"crocodile\" + DELIM: \"🐊\",\n    DELIM + r\"turtle\" + DELIM: \"🐢\",\n    DELIM + r\"lizard\" + DELIM: \"🦎\",\n    DELIM + r\"snake\" + DELIM: \"🐍\",\n    DELIM + r\"dragon_face\" + DELIM: \"🐲\",\n    DELIM + r\"dragon\" + DELIM: \"🐉\",\n    DELIM + r\"sauropod\" + DELIM: \"🦕\",\n    DELIM + r\"t-rex\" + DELIM: \"🦖\",\n    #\n    # Animal Marine\n    #\n    DELIM + r\"whale\" + DELIM: \"🐳\",\n    DELIM + r\"whale2\" + DELIM: \"🐋\",\n    DELIM + r\"dolphin\" + DELIM: \"🐬\",\n    DELIM + r\"(seal|flipper)\" + DELIM: \"🦭\",\n    DELIM + r\"fish\" + DELIM: \"🐟\",\n    DELIM + r\"tropical_fish\" + DELIM: \"🐠\",\n    DELIM + r\"blowfish\" + DELIM: \"🐡\",\n    DELIM + r\"shark\" + DELIM: \"🦈\",\n    DELIM + r\"octopus\" + DELIM: \"🐙\",\n    DELIM + r\"shell\" + DELIM: \"🐚\",\n    #\n    # Animal Bug\n    #\n    DELIM + r\"snail\" + DELIM: \"🐌\",\n    DELIM + r\"butterfly\" + DELIM: \"🦋\",\n    DELIM + r\"bug\" + DELIM: \"🐛\",\n    DELIM + r\"ant\" + DELIM: \"🐜\",\n    DELIM + r\"bee\" + DELIM: \"🐝\",\n    DELIM + r\"honeybee\" + DELIM: \"🪲\",\n    DELIM + r\"(lady_)?beetle\" + DELIM: \"🐞\",\n    DELIM + r\"cricket\" + DELIM: \"🦗\",\n    DELIM + r\"cockroach\" + DELIM: \"🪳\",\n    DELIM + r\"spider\" + DELIM: \"🕷️\",\n    DELIM + r\"spider_web\" + DELIM: \"🕸️\",\n    DELIM + r\"scorpion\" + DELIM: \"🦂\",\n    DELIM + r\"mosquito\" + DELIM: \"🦟\",\n    DELIM + r\"fly\" + DELIM: \"🪰\",\n    DELIM + r\"worm\" + DELIM: \"🪱\",\n    DELIM + r\"microbe\" + DELIM: \"🦠\",\n    #\n    # Plant Flower\n    #\n    DELIM + r\"bouquet\" + DELIM: \"💐\",\n    DELIM + r\"cherry_blossom\" + DELIM: \"🌸\",\n    DELIM + r\"white_flower\" + DELIM: \"💮\",\n    DELIM + r\"rosette\" + DELIM: \"🏵️\",\n    DELIM + r\"rose\" + DELIM: \"🌹\",\n    DELIM + r\"wilted_flower\" + DELIM: \"🥀\",\n    DELIM + r\"hibiscus\" + DELIM: \"🌺\",\n    DELIM + r\"sunflower\" + DELIM: \"🌻\",\n    DELIM + r\"blossom\" + DELIM: \"🌼\",\n    DELIM + r\"tulip\" + DELIM: \"🌷\",\n    #\n    # Plant Other\n    #\n    DELIM + r\"seedling\" + DELIM: \"🌱\",\n    DELIM + r\"potted_plant\" + DELIM: \"🪴\",\n    DELIM + r\"evergreen_tree\" + DELIM: \"🌲\",\n    DELIM + r\"deciduous_tree\" + DELIM: \"🌳\",\n    DELIM + r\"palm_tree\" + DELIM: \"🌴\",\n    DELIM + r\"cactus\" + DELIM: \"🌵\",\n    DELIM + r\"ear_of_rice\" + DELIM: \"🌾\",\n    DELIM + r\"herb\" + DELIM: \"🌿\",\n    DELIM + r\"shamrock\" + DELIM: \"☘️\",\n    DELIM + r\"four_leaf_clover\" + DELIM: \"🍀\",\n    DELIM + r\"maple_leaf\" + DELIM: \"🍁\",\n    DELIM + r\"fallen_leaf\" + DELIM: \"🍂\",\n    DELIM + r\"leaves\" + DELIM: \"🍃\",\n    DELIM + r\"mushroom\" + DELIM: \"🍄\",\n    #\n    # Food Fruit\n    #\n    DELIM + r\"grapes\" + DELIM: \"🍇\",\n    DELIM + r\"melon\" + DELIM: \"🍈\",\n    DELIM + r\"watermelon\" + DELIM: \"🍉\",\n    DELIM + r\"(orange|mandarin|tangerine)\" + DELIM: \"🍊\",\n    DELIM + r\"lemon\" + DELIM: \"🍋\",\n    DELIM + r\"banana\" + DELIM: \"🍌\",\n    DELIM + r\"pineapple\" + DELIM: \"🍍\",\n    DELIM + r\"mango\" + DELIM: \"🥭\",\n    DELIM + r\"apple\" + DELIM: \"🍎\",\n    DELIM + r\"green_apple\" + DELIM: \"🍏\",\n    DELIM + r\"pear\" + DELIM: \"🍐\",\n    DELIM + r\"peach\" + DELIM: \"🍑\",\n    DELIM + r\"cherries\" + DELIM: \"🍒\",\n    DELIM + r\"strawberry\" + DELIM: \"🍓\",\n    DELIM + r\"blueberries\" + DELIM: \"🫐\",\n    DELIM + r\"kiwi_fruit\" + DELIM: \"🥝\",\n    DELIM + r\"tomato\" + DELIM: \"🍅\",\n    DELIM + r\"olive\" + DELIM: \"🫒\",\n    DELIM + r\"coconut\" + DELIM: \"🥥\",\n    #\n    # Food Vegetable\n    #\n    DELIM + r\"avocado\" + DELIM: \"🥑\",\n    DELIM + r\"eggplant\" + DELIM: \"🍆\",\n    DELIM + r\"potato\" + DELIM: \"🥔\",\n    DELIM + r\"carrot\" + DELIM: \"🥕\",\n    DELIM + r\"corn\" + DELIM: \"🌽\",\n    DELIM + r\"hot_pepper\" + DELIM: \"🌶️\",\n    DELIM + r\"bell_pepper\" + DELIM: \"🫑\",\n    DELIM + r\"cucumber\" + DELIM: \"🥒\",\n    DELIM + r\"leafy_green\" + DELIM: \"🥬\",\n    DELIM + r\"broccoli\" + DELIM: \"🥦\",\n    DELIM + r\"garlic\" + DELIM: \"🧄\",\n    DELIM + r\"onion\" + DELIM: \"🧅\",\n    DELIM + r\"peanuts\" + DELIM: \"🥜\",\n    DELIM + r\"chestnut\" + DELIM: \"🌰\",\n    #\n    # Food Prepared\n    #\n    DELIM + r\"bread\" + DELIM: \"🍞\",\n    DELIM + r\"croissant\" + DELIM: \"🥐\",\n    DELIM + r\"baguette_bread\" + DELIM: \"🥖\",\n    DELIM + r\"flatbread\" + DELIM: \"🫓\",\n    DELIM + r\"pretzel\" + DELIM: \"🥨\",\n    DELIM + r\"bagel\" + DELIM: \"🥯\",\n    DELIM + r\"pancakes\" + DELIM: \"🥞\",\n    DELIM + r\"waffle\" + DELIM: \"🧇\",\n    DELIM + r\"cheese\" + DELIM: \"🧀\",\n    DELIM + r\"meat_on_bone\" + DELIM: \"🍖\",\n    DELIM + r\"poultry_leg\" + DELIM: \"🍗\",\n    DELIM + r\"cut_of_meat\" + DELIM: \"🥩\",\n    DELIM + r\"bacon\" + DELIM: \"🥓\",\n    DELIM + r\"hamburger\" + DELIM: \"🍔\",\n    DELIM + r\"fries\" + DELIM: \"🍟\",\n    DELIM + r\"pizza\" + DELIM: \"🍕\",\n    DELIM + r\"hotdog\" + DELIM: \"🌭\",\n    DELIM + r\"sandwich\" + DELIM: \"🥪\",\n    DELIM + r\"taco\" + DELIM: \"🌮\",\n    DELIM + r\"burrito\" + DELIM: \"🌯\",\n    DELIM + r\"tamale\" + DELIM: \"🫔\",\n    DELIM + r\"stuffed_flatbread\" + DELIM: \"🥙\",\n    DELIM + r\"falafel\" + DELIM: \"🧆\",\n    DELIM + r\"egg\" + DELIM: \"🥚\",\n    DELIM + r\"fried_egg\" + DELIM: \"🍳\",\n    DELIM + r\"shallow_pan_of_food\" + DELIM: \"🥘\",\n    DELIM + r\"stew\" + DELIM: \"🍲\",\n    DELIM + r\"fondue\" + DELIM: \"🫕\",\n    DELIM + r\"bowl_with_spoon\" + DELIM: \"🥣\",\n    DELIM + r\"green_salad\" + DELIM: \"🥗\",\n    DELIM + r\"popcorn\" + DELIM: \"🍿\",\n    DELIM + r\"butter\" + DELIM: \"🧈\",\n    DELIM + r\"salt\" + DELIM: \"🧂\",\n    DELIM + r\"canned_food\" + DELIM: \"🥫\",\n    #\n    # Food Asian\n    #\n    DELIM + r\"bento\" + DELIM: \"🍱\",\n    DELIM + r\"rice_cracker\" + DELIM: \"🍘\",\n    DELIM + r\"rice_ball\" + DELIM: \"🍙\",\n    DELIM + r\"rice\" + DELIM: \"🍚\",\n    DELIM + r\"curry\" + DELIM: \"🍛\",\n    DELIM + r\"ramen\" + DELIM: \"🍜\",\n    DELIM + r\"spaghetti\" + DELIM: \"🍝\",\n    DELIM + r\"sweet_potato\" + DELIM: \"🍠\",\n    DELIM + r\"oden\" + DELIM: \"🍢\",\n    DELIM + r\"sushi\" + DELIM: \"🍣\",\n    DELIM + r\"fried_shrimp\" + DELIM: \"🍤\",\n    DELIM + r\"fish_cake\" + DELIM: \"🍥\",\n    DELIM + r\"moon_cake\" + DELIM: \"🥮\",\n    DELIM + r\"dango\" + DELIM: \"🍡\",\n    DELIM + r\"dumpling\" + DELIM: \"🥟\",\n    DELIM + r\"fortune_cookie\" + DELIM: \"🥠\",\n    DELIM + r\"takeout_box\" + DELIM: \"🥡\",\n    #\n    # Food Marine\n    #\n    DELIM + r\"crab\" + DELIM: \"🦀\",\n    DELIM + r\"lobster\" + DELIM: \"🦞\",\n    DELIM + r\"shrimp\" + DELIM: \"🦐\",\n    DELIM + r\"squid\" + DELIM: \"🦑\",\n    DELIM + r\"oyster\" + DELIM: \"🦪\",\n    #\n    # Food Sweet\n    #\n    DELIM + r\"icecream\" + DELIM: \"🍦\",\n    DELIM + r\"shaved_ice\" + DELIM: \"🍧\",\n    DELIM + r\"ice_cream\" + DELIM: \"🍨\",\n    DELIM + r\"doughnut\" + DELIM: \"🍩\",\n    DELIM + r\"cookie\" + DELIM: \"🍪\",\n    DELIM + r\"birthday\" + DELIM: \"🎂\",\n    DELIM + r\"cake\" + DELIM: \"🍰\",\n    DELIM + r\"cupcake\" + DELIM: \"🧁\",\n    DELIM + r\"pie\" + DELIM: \"🥧\",\n    DELIM + r\"chocolate_bar\" + DELIM: \"🍫\",\n    DELIM + r\"candy\" + DELIM: \"🍬\",\n    DELIM + r\"lollipop\" + DELIM: \"🍭\",\n    DELIM + r\"custard\" + DELIM: \"🍮\",\n    DELIM + r\"honey_pot\" + DELIM: \"🍯\",\n    #\n    # Drink\n    #\n    DELIM + r\"baby_bottle\" + DELIM: \"🍼\",\n    DELIM + r\"milk_glass\" + DELIM: \"🥛\",\n    DELIM + r\"coffee\" + DELIM: \"☕\",\n    DELIM + r\"teapot\" + DELIM: \"🫖\",\n    DELIM + r\"tea\" + DELIM: \"🍵\",\n    DELIM + r\"sake\" + DELIM: \"🍶\",\n    DELIM + r\"champagne\" + DELIM: \"🍾\",\n    DELIM + r\"wine_glass\" + DELIM: \"🍷\",\n    DELIM + r\"cocktail\" + DELIM: \"🍸\",\n    DELIM + r\"tropical_drink\" + DELIM: \"🍹\",\n    DELIM + r\"beer\" + DELIM: \"🍺\",\n    DELIM + r\"beers\" + DELIM: \"🍻\",\n    DELIM + r\"clinking_glasses\" + DELIM: \"🥂\",\n    DELIM + r\"tumbler_glass\" + DELIM: \"🥃\",\n    DELIM + r\"cup_with_straw\" + DELIM: \"🥤\",\n    DELIM + r\"bubble_tea\" + DELIM: \"🧋\",\n    DELIM + r\"beverage_box\" + DELIM: \"🧃\",\n    DELIM + r\"mate\" + DELIM: \"🧉\",\n    DELIM + r\"ice_cube\" + DELIM: \"🧊\",\n    #\n    # Dishware\n    #\n    DELIM + r\"chopsticks\" + DELIM: \"🥢\",\n    DELIM + r\"plate_with_cutlery\" + DELIM: \"🍽️\",\n    DELIM + r\"fork_and_knife\" + DELIM: \"🍴\",\n    DELIM + r\"spoon\" + DELIM: \"🥄\",\n    DELIM + r\"(hocho|knife)\" + DELIM: \"🔪\",\n    DELIM + r\"amphora\" + DELIM: \"🏺\",\n    #\n    # Place Map\n    #\n    DELIM + r\"earth_africa\" + DELIM: \"🌍\",\n    DELIM + r\"earth_americas\" + DELIM: \"🌎\",\n    DELIM + r\"earth_asia\" + DELIM: \"🌏\",\n    DELIM + r\"globe_with_meridians\" + DELIM: \"🌐\",\n    DELIM + r\"world_map\" + DELIM: \"🗺️\",\n    DELIM + r\"japan\" + DELIM: \"🗾\",\n    DELIM + r\"compass\" + DELIM: \"🧭\",\n    #\n    # Place Geographic\n    #\n    DELIM + r\"mountain_snow\" + DELIM: \"🏔️\",\n    DELIM + r\"mountain\" + DELIM: \"⛰️\",\n    DELIM + r\"volcano\" + DELIM: \"🌋\",\n    DELIM + r\"mount_fuji\" + DELIM: \"🗻\",\n    DELIM + r\"camping\" + DELIM: \"🏕️\",\n    DELIM + r\"beach_umbrella\" + DELIM: \"🏖️\",\n    DELIM + r\"desert\" + DELIM: \"🏜️\",\n    DELIM + r\"desert_island\" + DELIM: \"🏝️\",\n    DELIM + r\"national_park\" + DELIM: \"🏞️\",\n    #\n    # Place Building\n    #\n    DELIM + r\"stadium\" + DELIM: \"🏟️\",\n    DELIM + r\"classical_building\" + DELIM: \"🏛️\",\n    DELIM + r\"building_construction\" + DELIM: \"🏗️\",\n    DELIM + r\"bricks\" + DELIM: \"🧱\",\n    DELIM + r\"rock\" + DELIM: \"🪨\",\n    DELIM + r\"wood\" + DELIM: \"🪵\",\n    DELIM + r\"hut\" + DELIM: \"🛖\",\n    DELIM + r\"houses\" + DELIM: \"🏘️\",\n    DELIM + r\"derelict_house\" + DELIM: \"🏚️\",\n    DELIM + r\"house\" + DELIM: \"🏠\",\n    DELIM + r\"house_with_garden\" + DELIM: \"🏡\",\n    DELIM + r\"office\" + DELIM: \"🏢\",\n    DELIM + r\"post_office\" + DELIM: \"🏣\",\n    DELIM + r\"european_post_office\" + DELIM: \"🏤\",\n    DELIM + r\"hospital\" + DELIM: \"🏥\",\n    DELIM + r\"bank\" + DELIM: \"🏦\",\n    DELIM + r\"hotel\" + DELIM: \"🏨\",\n    DELIM + r\"love_hotel\" + DELIM: \"🏩\",\n    DELIM + r\"convenience_store\" + DELIM: \"🏪\",\n    DELIM + r\"school\" + DELIM: \"🏫\",\n    DELIM + r\"department_store\" + DELIM: \"🏬\",\n    DELIM + r\"factory\" + DELIM: \"🏭\",\n    DELIM + r\"japanese_castle\" + DELIM: \"🏯\",\n    DELIM + r\"european_castle\" + DELIM: \"🏰\",\n    DELIM + r\"wedding\" + DELIM: \"💒\",\n    DELIM + r\"tokyo_tower\" + DELIM: \"🗼\",\n    DELIM + r\"statue_of_liberty\" + DELIM: \"🗽\",\n    #\n    # Place Religious\n    #\n    DELIM + r\"church\" + DELIM: \"⛪\",\n    DELIM + r\"mosque\" + DELIM: \"🕌\",\n    DELIM + r\"hindu_temple\" + DELIM: \"🛕\",\n    DELIM + r\"synagogue\" + DELIM: \"🕍\",\n    DELIM + r\"shinto_shrine\" + DELIM: \"⛩️\",\n    DELIM + r\"kaaba\" + DELIM: \"🕋\",\n    #\n    # Place Other\n    #\n    DELIM + r\"fountain\" + DELIM: \"⛲\",\n    DELIM + r\"tent\" + DELIM: \"⛺\",\n    DELIM + r\"foggy\" + DELIM: \"🌁\",\n    DELIM + r\"night_with_stars\" + DELIM: \"🌃\",\n    DELIM + r\"cityscape\" + DELIM: \"🏙️\",\n    DELIM + r\"sunrise_over_mountains\" + DELIM: \"🌄\",\n    DELIM + r\"sunrise\" + DELIM: \"🌅\",\n    DELIM + r\"city_sunset\" + DELIM: \"🌆\",\n    DELIM + r\"city_sunrise\" + DELIM: \"🌇\",\n    DELIM + r\"bridge_at_night\" + DELIM: \"🌉\",\n    DELIM + r\"hotsprings\" + DELIM: \"♨️\",\n    DELIM + r\"carousel_horse\" + DELIM: \"🎠\",\n    DELIM + r\"ferris_wheel\" + DELIM: \"🎡\",\n    DELIM + r\"roller_coaster\" + DELIM: \"🎢\",\n    DELIM + r\"barber\" + DELIM: \"💈\",\n    DELIM + r\"circus_tent\" + DELIM: \"🎪\",\n    #\n    # Transport Ground\n    #\n    DELIM + r\"steam_locomotive\" + DELIM: \"🚂\",\n    DELIM + r\"railway_car\" + DELIM: \"🚃\",\n    DELIM + r\"bullettrain_side\" + DELIM: \"🚄\",\n    DELIM + r\"bullettrain_front\" + DELIM: \"🚅\",\n    DELIM + r\"train2\" + DELIM: \"🚆\",\n    DELIM + r\"metro\" + DELIM: \"🚇\",\n    DELIM + r\"light_rail\" + DELIM: \"🚈\",\n    DELIM + r\"station\" + DELIM: \"🚉\",\n    DELIM + r\"tram\" + DELIM: \"🚊\",\n    DELIM + r\"monorail\" + DELIM: \"🚝\",\n    DELIM + r\"mountain_railway\" + DELIM: \"🚞\",\n    DELIM + r\"train\" + DELIM: \"🚋\",\n    DELIM + r\"bus\" + DELIM: \"🚌\",\n    DELIM + r\"oncoming_bus\" + DELIM: \"🚍\",\n    DELIM + r\"trolleybus\" + DELIM: \"🚎\",\n    DELIM + r\"minibus\" + DELIM: \"🚐\",\n    DELIM + r\"ambulance\" + DELIM: \"🚑\",\n    DELIM + r\"fire_engine\" + DELIM: \"🚒\",\n    DELIM + r\"police_car\" + DELIM: \"🚓\",\n    DELIM + r\"oncoming_police_car\" + DELIM: \"🚔\",\n    DELIM + r\"taxi\" + DELIM: \"🚕\",\n    DELIM + r\"oncoming_taxi\" + DELIM: \"🚖\",\n    DELIM + r\"car\" + DELIM: \"🚗\",\n    DELIM + r\"(red_car|oncoming_automobile)\" + DELIM: \"🚘\",\n    DELIM + r\"blue_car\" + DELIM: \"🚙\",\n    DELIM + r\"pickup_truck\" + DELIM: \"🛻\",\n    DELIM + r\"truck\" + DELIM: \"🚚\",\n    DELIM + r\"articulated_lorry\" + DELIM: \"🚛\",\n    DELIM + r\"tractor\" + DELIM: \"🚜\",\n    DELIM + r\"racing_car\" + DELIM: \"🏎️\",\n    DELIM + r\"motorcycle\" + DELIM: \"🏍️\",\n    DELIM + r\"motor_scooter\" + DELIM: \"🛵\",\n    DELIM + r\"manual_wheelchair\" + DELIM: \"🦽\",\n    DELIM + r\"motorized_wheelchair\" + DELIM: \"🦼\",\n    DELIM + r\"auto_rickshaw\" + DELIM: \"🛺\",\n    DELIM + r\"bike\" + DELIM: \"🚲\",\n    DELIM + r\"kick_scooter\" + DELIM: \"🛴\",\n    DELIM + r\"skateboard\" + DELIM: \"🛹\",\n    DELIM + r\"roller_skate\" + DELIM: \"🛼\",\n    DELIM + r\"busstop\" + DELIM: \"🚏\",\n    DELIM + r\"motorway\" + DELIM: \"🛣️\",\n    DELIM + r\"railway_track\" + DELIM: \"🛤️\",\n    DELIM + r\"oil_drum\" + DELIM: \"🛢️\",\n    DELIM + r\"fuelpump\" + DELIM: \"⛽\",\n    DELIM + r\"rotating_light\" + DELIM: \"🚨\",\n    DELIM + r\"traffic_light\" + DELIM: \"🚥\",\n    DELIM + r\"vertical_traffic_light\" + DELIM: \"🚦\",\n    DELIM + r\"stop_sign\" + DELIM: \"🛑\",\n    DELIM + r\"construction\" + DELIM: \"🚧\",\n    #\n    # Transport Water\n    #\n    DELIM + r\"anchor\" + DELIM: \"⚓\",\n    DELIM + r\"(sailboat|boat)\" + DELIM: \"⛵\",\n    DELIM + r\"canoe\" + DELIM: \"🛶\",\n    DELIM + r\"speedboat\" + DELIM: \"🚤\",\n    DELIM + r\"passenger_ship\" + DELIM: \"🛳️\",\n    DELIM + r\"ferry\" + DELIM: \"⛴️\",\n    DELIM + r\"motor_boat\" + DELIM: \"🛥️\",\n    DELIM + r\"ship\" + DELIM: \"🚢\",\n    #\n    # Transport Air\n    #\n    DELIM + r\"airplane\" + DELIM: \"✈️\",\n    DELIM + r\"small_airplane\" + DELIM: \"🛩️\",\n    DELIM + r\"flight_departure\" + DELIM: \"🛫\",\n    DELIM + r\"flight_arrival\" + DELIM: \"🛬\",\n    DELIM + r\"parachute\" + DELIM: \"🪂\",\n    DELIM + r\"seat\" + DELIM: \"💺\",\n    DELIM + r\"helicopter\" + DELIM: \"🚁\",\n    DELIM + r\"suspension_railway\" + DELIM: \"🚟\",\n    DELIM + r\"mountain_cableway\" + DELIM: \"🚠\",\n    DELIM + r\"aerial_tramway\" + DELIM: \"🚡\",\n    DELIM + r\"artificial_satellite\" + DELIM: \"🛰️\",\n    DELIM + r\"rocket\" + DELIM: \"🚀\",\n    DELIM + r\"flying_saucer\" + DELIM: \"🛸\",\n    #\n    # Hotel\n    #\n    DELIM + r\"bellhop_bell\" + DELIM: \"🛎️\",\n    DELIM + r\"luggage\" + DELIM: \"🧳\",\n    #\n    # Time\n    #\n    DELIM + r\"hourglass\" + DELIM: \"⌛\",\n    DELIM + r\"hourglass_flowing_sand\" + DELIM: \"⏳\",\n    DELIM + r\"watch\" + DELIM: \"⌚\",\n    DELIM + r\"alarm_clock\" + DELIM: \"⏰\",\n    DELIM + r\"stopwatch\" + DELIM: \"⏱️\",\n    DELIM + r\"timer_clock\" + DELIM: \"⏲️\",\n    DELIM + r\"mantelpiece_clock\" + DELIM: \"🕰️\",\n    DELIM + r\"clock12\" + DELIM: \"🕛\",\n    DELIM + r\"clock1230\" + DELIM: \"🕧\",\n    DELIM + r\"clock1\" + DELIM: \"🕐\",\n    DELIM + r\"clock130\" + DELIM: \"🕜\",\n    DELIM + r\"clock2\" + DELIM: \"🕑\",\n    DELIM + r\"clock230\" + DELIM: \"🕝\",\n    DELIM + r\"clock3\" + DELIM: \"🕒\",\n    DELIM + r\"clock330\" + DELIM: \"🕞\",\n    DELIM + r\"clock4\" + DELIM: \"🕓\",\n    DELIM + r\"clock430\" + DELIM: \"🕟\",\n    DELIM + r\"clock5\" + DELIM: \"🕔\",\n    DELIM + r\"clock530\" + DELIM: \"🕠\",\n    DELIM + r\"clock6\" + DELIM: \"🕕\",\n    DELIM + r\"clock630\" + DELIM: \"🕡\",\n    DELIM + r\"clock7\" + DELIM: \"🕖\",\n    DELIM + r\"clock730\" + DELIM: \"🕢\",\n    DELIM + r\"clock8\" + DELIM: \"🕗\",\n    DELIM + r\"clock830\" + DELIM: \"🕣\",\n    DELIM + r\"clock9\" + DELIM: \"🕘\",\n    DELIM + r\"clock930\" + DELIM: \"🕤\",\n    DELIM + r\"clock10\" + DELIM: \"🕙\",\n    DELIM + r\"clock1030\" + DELIM: \"🕥\",\n    DELIM + r\"clock11\" + DELIM: \"🕚\",\n    DELIM + r\"clock1130\" + DELIM: \"🕦\",\n    # Sky & Weather\n    DELIM + r\"new_moon\" + DELIM: \"🌑\",\n    DELIM + r\"waxing_crescent_moon\" + DELIM: \"🌒\",\n    DELIM + r\"first_quarter_moon\" + DELIM: \"🌓\",\n    DELIM + r\"moon\" + DELIM: \"🌔\",\n    DELIM + r\"(waxing_gibbous_moon|full_moon)\" + DELIM: \"🌕\",\n    DELIM + r\"waning_gibbous_moon\" + DELIM: \"🌖\",\n    DELIM + r\"last_quarter_moon\" + DELIM: \"🌗\",\n    DELIM + r\"waning_crescent_moon\" + DELIM: \"🌘\",\n    DELIM + r\"crescent_moon\" + DELIM: \"🌙\",\n    DELIM + r\"new_moon_with_face\" + DELIM: \"🌚\",\n    DELIM + r\"first_quarter_moon_with_face\" + DELIM: \"🌛\",\n    DELIM + r\"last_quarter_moon_with_face\" + DELIM: \"🌜\",\n    DELIM + r\"thermometer\" + DELIM: \"🌡️\",\n    DELIM + r\"sunny\" + DELIM: \"☀️\",\n    DELIM + r\"full_moon_with_face\" + DELIM: \"🌝\",\n    DELIM + r\"sun_with_face\" + DELIM: \"🌞\",\n    DELIM + r\"ringed_planet\" + DELIM: \"🪐\",\n    DELIM + r\"star\" + DELIM: \"⭐\",\n    DELIM + r\"star2\" + DELIM: \"🌟\",\n    DELIM + r\"stars\" + DELIM: \"🌠\",\n    DELIM + r\"milky_way\" + DELIM: \"🌌\",\n    DELIM + r\"cloud\" + DELIM: \"☁️\",\n    DELIM + r\"partly_sunny\" + DELIM: \"⛅\",\n    DELIM + r\"cloud_with_lightning_and_rain\" + DELIM: \"⛈️\",\n    DELIM + r\"sun_behind_small_cloud\" + DELIM: \"🌤️\",\n    DELIM + r\"sun_behind_large_cloud\" + DELIM: \"🌥️\",\n    DELIM + r\"sun_behind_rain_cloud\" + DELIM: \"🌦️\",\n    DELIM + r\"cloud_with_rain\" + DELIM: \"🌧️\",\n    DELIM + r\"cloud_with_snow\" + DELIM: \"🌨️\",\n    DELIM + r\"cloud_with_lightning\" + DELIM: \"🌩️\",\n    DELIM + r\"tornado\" + DELIM: \"🌪️\",\n    DELIM + r\"fog\" + DELIM: \"🌫️\",\n    DELIM + r\"wind_face\" + DELIM: \"🌬️\",\n    DELIM + r\"cyclone\" + DELIM: \"🌀\",\n    DELIM + r\"rainbow\" + DELIM: \"🌈\",\n    DELIM + r\"closed_umbrella\" + DELIM: \"🌂\",\n    DELIM + r\"open_umbrella\" + DELIM: \"☂️\",\n    DELIM + r\"umbrella\" + DELIM: \"☔\",\n    DELIM + r\"parasol_on_ground\" + DELIM: \"⛱️\",\n    DELIM + r\"zap\" + DELIM: \"⚡\",\n    DELIM + r\"snowflake\" + DELIM: \"❄️\",\n    DELIM + r\"snowman_with_snow\" + DELIM: \"☃️\",\n    DELIM + r\"snowman\" + DELIM: \"⛄\",\n    DELIM + r\"comet\" + DELIM: \"☄️\",\n    DELIM + r\"fire\" + DELIM: \"🔥\",\n    DELIM + r\"droplet\" + DELIM: \"💧\",\n    DELIM + r\"ocean\" + DELIM: \"🌊\",\n    #\n    # Event\n    #\n    DELIM + r\"jack_o_lantern\" + DELIM: \"🎃\",\n    DELIM + r\"christmas_tree\" + DELIM: \"🎄\",\n    DELIM + r\"fireworks\" + DELIM: \"🎆\",\n    DELIM + r\"sparkler\" + DELIM: \"🎇\",\n    DELIM + r\"firecracker\" + DELIM: \"🧨\",\n    DELIM + r\"sparkles\" + DELIM: \"✨\",\n    DELIM + r\"balloon\" + DELIM: \"🎈\",\n    DELIM + r\"tada\" + DELIM: \"🎉\",\n    DELIM + r\"confetti_ball\" + DELIM: \"🎊\",\n    DELIM + r\"tanabata_tree\" + DELIM: \"🎋\",\n    DELIM + r\"bamboo\" + DELIM: \"🎍\",\n    DELIM + r\"dolls\" + DELIM: \"🎎\",\n    DELIM + r\"flags\" + DELIM: \"🎏\",\n    DELIM + r\"wind_chime\" + DELIM: \"🎐\",\n    DELIM + r\"rice_scene\" + DELIM: \"🎑\",\n    DELIM + r\"red_envelope\" + DELIM: \"🧧\",\n    DELIM + r\"ribbon\" + DELIM: \"🎀\",\n    DELIM + r\"gift\" + DELIM: \"🎁\",\n    DELIM + r\"reminder_ribbon\" + DELIM: \"🎗️\",\n    DELIM + r\"tickets\" + DELIM: \"🎟️\",\n    DELIM + r\"ticket\" + DELIM: \"🎫\",\n    #\n    # Award Medal\n    #\n    DELIM + r\"medal_military\" + DELIM: \"🎖️\",\n    DELIM + r\"trophy\" + DELIM: \"🏆\",\n    DELIM + r\"medal_sports\" + DELIM: \"🏅\",\n    DELIM + r\"1st_place_medal\" + DELIM: \"🥇\",\n    DELIM + r\"2nd_place_medal\" + DELIM: \"🥈\",\n    DELIM + r\"3rd_place_medal\" + DELIM: \"🥉\",\n    #\n    # Sport\n    #\n    DELIM + r\"soccer\" + DELIM: \"⚽\",\n    DELIM + r\"baseball\" + DELIM: \"⚾\",\n    DELIM + r\"softball\" + DELIM: \"🥎\",\n    DELIM + r\"basketball\" + DELIM: \"🏀\",\n    DELIM + r\"volleyball\" + DELIM: \"🏐\",\n    DELIM + r\"football\" + DELIM: \"🏈\",\n    DELIM + r\"rugby_football\" + DELIM: \"🏉\",\n    DELIM + r\"tennis\" + DELIM: \"🎾\",\n    DELIM + r\"flying_disc\" + DELIM: \"🥏\",\n    DELIM + r\"bowling\" + DELIM: \"🎳\",\n    DELIM + r\"cricket_game\" + DELIM: \"🏏\",\n    DELIM + r\"field_hockey\" + DELIM: \"🏑\",\n    DELIM + r\"ice_hockey\" + DELIM: \"🏒\",\n    DELIM + r\"lacrosse\" + DELIM: \"🥍\",\n    DELIM + r\"ping_pong\" + DELIM: \"🏓\",\n    DELIM + r\"badminton\" + DELIM: \"🏸\",\n    DELIM + r\"boxing_glove\" + DELIM: \"🥊\",\n    DELIM + r\"martial_arts_uniform\" + DELIM: \"🥋\",\n    DELIM + r\"goal_net\" + DELIM: \"🥅\",\n    DELIM + r\"golf\" + DELIM: \"⛳\",\n    DELIM + r\"ice_skate\" + DELIM: \"⛸️\",\n    DELIM + r\"fishing_pole_and_fish\" + DELIM: \"🎣\",\n    DELIM + r\"diving_mask\" + DELIM: \"🤿\",\n    DELIM + r\"running_shirt_with_sash\" + DELIM: \"🎽\",\n    DELIM + r\"ski\" + DELIM: \"🎿\",\n    DELIM + r\"sled\" + DELIM: \"🛷\",\n    DELIM + r\"curling_stone\" + DELIM: \"🥌\",\n    #\n    # Game\n    #\n    DELIM + r\"dart\" + DELIM: \"🎯\",\n    DELIM + r\"yo_yo\" + DELIM: \"🪀\",\n    DELIM + r\"kite\" + DELIM: \"🪁\",\n    DELIM + r\"gun\" + DELIM: \"🔫\",\n    DELIM + r\"8ball\" + DELIM: \"🎱\",\n    DELIM + r\"crystal_ball\" + DELIM: \"🔮\",\n    DELIM + r\"magic_wand\" + DELIM: \"🪄\",\n    DELIM + r\"video_game\" + DELIM: \"🎮\",\n    DELIM + r\"joystick\" + DELIM: \"🕹️\",\n    DELIM + r\"slot_machine\" + DELIM: \"🎰\",\n    DELIM + r\"game_die\" + DELIM: \"🎲\",\n    DELIM + r\"jigsaw\" + DELIM: \"🧩\",\n    DELIM + r\"teddy_bear\" + DELIM: \"🧸\",\n    DELIM + r\"pinata\" + DELIM: \"🪅\",\n    DELIM + r\"nesting_dolls\" + DELIM: \"🪆\",\n    DELIM + r\"spades\" + DELIM: \"♠️\",\n    DELIM + r\"hearts\" + DELIM: \"♥️\",\n    DELIM + r\"diamonds\" + DELIM: \"♦️\",\n    DELIM + r\"clubs\" + DELIM: \"♣️\",\n    DELIM + r\"chess_pawn\" + DELIM: \"♟️\",\n    DELIM + r\"black_joker\" + DELIM: \"🃏\",\n    DELIM + r\"mahjong\" + DELIM: \"🀄\",\n    DELIM + r\"flower_playing_cards\" + DELIM: \"🎴\",\n    #\n    # Arts & Crafts\n    #\n    DELIM + r\"performing_arts\" + DELIM: \"🎭\",\n    DELIM + r\"framed_picture\" + DELIM: \"🖼️\",\n    DELIM + r\"art\" + DELIM: \"🎨\",\n    DELIM + r\"thread\" + DELIM: \"🧵\",\n    DELIM + r\"sewing_needle\" + DELIM: \"🪡\",\n    DELIM + r\"yarn\" + DELIM: \"🧶\",\n    DELIM + r\"knot\" + DELIM: \"🪢\",\n    #\n    # Clothing\n    #\n    DELIM + r\"eyeglasses\" + DELIM: \"👓\",\n    DELIM + r\"dark_sunglasses\" + DELIM: \"🕶️\",\n    DELIM + r\"goggles\" + DELIM: \"🥽\",\n    DELIM + r\"lab_coat\" + DELIM: \"🥼\",\n    DELIM + r\"safety_vest\" + DELIM: \"🦺\",\n    DELIM + r\"necktie\" + DELIM: \"👔\",\n    DELIM + r\"t?shirt\" + DELIM: \"👕\",\n    DELIM + r\"jeans\" + DELIM: \"👖\",\n    DELIM + r\"scarf\" + DELIM: \"🧣\",\n    DELIM + r\"gloves\" + DELIM: \"🧤\",\n    DELIM + r\"coat\" + DELIM: \"🧥\",\n    DELIM + r\"socks\" + DELIM: \"🧦\",\n    DELIM + r\"dress\" + DELIM: \"👗\",\n    DELIM + r\"kimono\" + DELIM: \"👘\",\n    DELIM + r\"sari\" + DELIM: \"🥻\",\n    DELIM + r\"one_piece_swimsuit\" + DELIM: \"🩱\",\n    DELIM + r\"swim_brief\" + DELIM: \"🩲\",\n    DELIM + r\"shorts\" + DELIM: \"🩳\",\n    DELIM + r\"bikini\" + DELIM: \"👙\",\n    DELIM + r\"womans_clothes\" + DELIM: \"👚\",\n    DELIM + r\"purse\" + DELIM: \"👛\",\n    DELIM + r\"handbag\" + DELIM: \"👜\",\n    DELIM + r\"pouch\" + DELIM: \"👝\",\n    DELIM + r\"shopping\" + DELIM: \"🛍️\",\n    DELIM + r\"school_satchel\" + DELIM: \"🎒\",\n    DELIM + r\"thong_sandal\" + DELIM: \"🩴\",\n    DELIM + r\"(mans_)?shoe\" + DELIM: \"👞\",\n    DELIM + r\"athletic_shoe\" + DELIM: \"👟\",\n    DELIM + r\"hiking_boot\" + DELIM: \"🥾\",\n    DELIM + r\"flat_shoe\" + DELIM: \"🥿\",\n    DELIM + r\"high_heel\" + DELIM: \"👠\",\n    DELIM + r\"sandal\" + DELIM: \"👡\",\n    DELIM + r\"ballet_shoes\" + DELIM: \"🩰\",\n    DELIM + r\"boot\" + DELIM: \"👢\",\n    DELIM + r\"crown\" + DELIM: \"👑\",\n    DELIM + r\"womans_hat\" + DELIM: \"👒\",\n    DELIM + r\"tophat\" + DELIM: \"🎩\",\n    DELIM + r\"mortar_board\" + DELIM: \"🎓\",\n    DELIM + r\"billed_cap\" + DELIM: \"🧢\",\n    DELIM + r\"military_helmet\" + DELIM: \"🪖\",\n    DELIM + r\"rescue_worker_helmet\" + DELIM: \"⛑️\",\n    DELIM + r\"prayer_beads\" + DELIM: \"📿\",\n    DELIM + r\"lipstick\" + DELIM: \"💄\",\n    DELIM + r\"ring\" + DELIM: \"💍\",\n    DELIM + r\"gem\" + DELIM: \"💎\",\n    #\n    # Sound\n    #\n    DELIM + r\"mute\" + DELIM: \"🔇\",\n    DELIM + r\"speaker\" + DELIM: \"🔈\",\n    DELIM + r\"sound\" + DELIM: \"🔉\",\n    DELIM + r\"loud_sound\" + DELIM: \"🔊\",\n    DELIM + r\"loudspeaker\" + DELIM: \"📢\",\n    DELIM + r\"mega\" + DELIM: \"📣\",\n    DELIM + r\"postal_horn\" + DELIM: \"📯\",\n    DELIM + r\"bell\" + DELIM: \"🔔\",\n    DELIM + r\"no_bell\" + DELIM: \"🔕\",\n    #\n    # Music\n    #\n    DELIM + r\"musical_score\" + DELIM: \"🎼\",\n    DELIM + r\"musical_note\" + DELIM: \"🎵\",\n    DELIM + r\"notes\" + DELIM: \"🎶\",\n    DELIM + r\"studio_microphone\" + DELIM: \"🎙️\",\n    DELIM + r\"level_slider\" + DELIM: \"🎚️\",\n    DELIM + r\"control_knobs\" + DELIM: \"🎛️\",\n    DELIM + r\"microphone\" + DELIM: \"🎤\",\n    DELIM + r\"headphones\" + DELIM: \"🎧\",\n    DELIM + r\"radio\" + DELIM: \"📻\",\n    #\n    # Musical Instrument\n    #\n    DELIM + r\"saxophone\" + DELIM: \"🎷\",\n    DELIM + r\"accordion\" + DELIM: \"🪗\",\n    DELIM + r\"guitar\" + DELIM: \"🎸\",\n    DELIM + r\"musical_keyboard\" + DELIM: \"🎹\",\n    DELIM + r\"trumpet\" + DELIM: \"🎺\",\n    DELIM + r\"violin\" + DELIM: \"🎻\",\n    DELIM + r\"banjo\" + DELIM: \"🪕\",\n    DELIM + r\"drum\" + DELIM: \"🥁\",\n    DELIM + r\"long_drum\" + DELIM: \"🪘\",\n    #\n    # Phone\n    #\n    DELIM + r\"iphone\" + DELIM: \"📱\",\n    DELIM + r\"calling\" + DELIM: \"📲\",\n    DELIM + r\"phone\" + DELIM: \"☎️\",\n    DELIM + r\"telephone(_receiver)?\" + DELIM: \"📞\",\n    DELIM + r\"pager\" + DELIM: \"📟\",\n    DELIM + r\"fax\" + DELIM: \"📠\",\n    #\n    # Computer\n    #\n    DELIM + r\"battery\" + DELIM: \"🔋\",\n    DELIM + r\"electric_plug\" + DELIM: \"🔌\",\n    DELIM + r\"computer\" + DELIM: \"💻\",\n    DELIM + r\"desktop_computer\" + DELIM: \"🖥️\",\n    DELIM + r\"printer\" + DELIM: \"🖨️\",\n    DELIM + r\"keyboard\" + DELIM: \"⌨️\",\n    DELIM + r\"computer_mouse\" + DELIM: \"🖱️\",\n    DELIM + r\"trackball\" + DELIM: \"🖲️\",\n    DELIM + r\"minidisc\" + DELIM: \"💽\",\n    DELIM + r\"floppy_disk\" + DELIM: \"💾\",\n    DELIM + r\"cd\" + DELIM: \"💿\",\n    DELIM + r\"dvd\" + DELIM: \"📀\",\n    DELIM + r\"abacus\" + DELIM: \"🧮\",\n    #\n    # Light & Video\n    #\n    DELIM + r\"movie_camera\" + DELIM: \"🎥\",\n    DELIM + r\"film_strip\" + DELIM: \"🎞️\",\n    DELIM + r\"film_projector\" + DELIM: \"📽️\",\n    DELIM + r\"clapper\" + DELIM: \"🎬\",\n    DELIM + r\"tv\" + DELIM: \"📺\",\n    DELIM + r\"camera\" + DELIM: \"📷\",\n    DELIM + r\"camera_flash\" + DELIM: \"📸\",\n    DELIM + r\"video_camera\" + DELIM: \"📹\",\n    DELIM + r\"vhs\" + DELIM: \"📼\",\n    DELIM + r\"mag\" + DELIM: \"🔍\",\n    DELIM + r\"mag_right\" + DELIM: \"🔎\",\n    DELIM + r\"candle\" + DELIM: \"🕯️\",\n    DELIM + r\"bulb\" + DELIM: \"💡\",\n    DELIM + r\"flashlight\" + DELIM: \"🔦\",\n    DELIM + r\"(izakaya_)?lantern\" + DELIM: \"🏮\",\n    DELIM + r\"diya_lamp\" + DELIM: \"🪔\",\n    #\n    # Book Paper\n    #\n    DELIM + r\"notebook_with_decorative_cover\" + DELIM: \"📔\",\n    DELIM + r\"closed_book\" + DELIM: \"📕\",\n    DELIM + r\"(open_)?book\" + DELIM: \"📖\",\n    DELIM + r\"green_book\" + DELIM: \"📗\",\n    DELIM + r\"blue_book\" + DELIM: \"📘\",\n    DELIM + r\"orange_book\" + DELIM: \"📙\",\n    DELIM + r\"books\" + DELIM: \"📚\",\n    DELIM + r\"notebook\" + DELIM: \"📓\",\n    DELIM + r\"ledger\" + DELIM: \"📒\",\n    DELIM + r\"page_with_curl\" + DELIM: \"📃\",\n    DELIM + r\"scroll\" + DELIM: \"📜\",\n    DELIM + r\"page_facing_up\" + DELIM: \"📄\",\n    DELIM + r\"newspaper\" + DELIM: \"📰\",\n    DELIM + r\"newspaper_roll\" + DELIM: \"🗞️\",\n    DELIM + r\"bookmark_tabs\" + DELIM: \"📑\",\n    DELIM + r\"bookmark\" + DELIM: \"🔖\",\n    DELIM + r\"label\" + DELIM: \"🏷️\",\n    #\n    # Money\n    #\n    DELIM + r\"moneybag\" + DELIM: \"💰\",\n    DELIM + r\"coin\" + DELIM: \"🪙\",\n    DELIM + r\"yen\" + DELIM: \"💴\",\n    DELIM + r\"dollar\" + DELIM: \"💵\",\n    DELIM + r\"euro\" + DELIM: \"💶\",\n    DELIM + r\"pound\" + DELIM: \"💷\",\n    DELIM + r\"money_with_wings\" + DELIM: \"💸\",\n    DELIM + r\"credit_card\" + DELIM: \"💳\",\n    DELIM + r\"receipt\" + DELIM: \"🧾\",\n    DELIM + r\"chart\" + DELIM: \"💹\",\n    #\n    # Mail\n    #\n    DELIM + r\"envelope\" + DELIM: \"✉️\",\n    DELIM + r\"e-?mail\" + DELIM: \"📧\",\n    DELIM + r\"incoming_envelope\" + DELIM: \"📨\",\n    DELIM + r\"envelope_with_arrow\" + DELIM: \"📩\",\n    DELIM + r\"outbox_tray\" + DELIM: \"📤\",\n    DELIM + r\"inbox_tray\" + DELIM: \"📥\",\n    DELIM + r\"package\" + DELIM: \"📦\",\n    DELIM + r\"mailbox\" + DELIM: \"📫\",\n    DELIM + r\"mailbox_closed\" + DELIM: \"📪\",\n    DELIM + r\"mailbox_with_mail\" + DELIM: \"📬\",\n    DELIM + r\"mailbox_with_no_mail\" + DELIM: \"📭\",\n    DELIM + r\"postbox\" + DELIM: \"📮\",\n    DELIM + r\"ballot_box\" + DELIM: \"🗳️\",\n    #\n    # Writing\n    #\n    DELIM + r\"pencil2\" + DELIM: \"✏️\",\n    DELIM + r\"black_nib\" + DELIM: \"✒️\",\n    DELIM + r\"fountain_pen\" + DELIM: \"🖋️\",\n    DELIM + r\"pen\" + DELIM: \"🖊️\",\n    DELIM + r\"paintbrush\" + DELIM: \"🖌️\",\n    DELIM + r\"crayon\" + DELIM: \"🖍️\",\n    DELIM + r\"(memo|pencil)\" + DELIM: \"📝\",\n    #\n    # Office\n    #\n    DELIM + r\"briefcase\" + DELIM: \"💼\",\n    DELIM + r\"file_folder\" + DELIM: \"📁\",\n    DELIM + r\"open_file_folder\" + DELIM: \"📂\",\n    DELIM + r\"card_index_dividers\" + DELIM: \"🗂️\",\n    DELIM + r\"date\" + DELIM: \"📅\",\n    DELIM + r\"calendar\" + DELIM: \"📆\",\n    DELIM + r\"spiral_notepad\" + DELIM: \"🗒️\",\n    DELIM + r\"spiral_calendar\" + DELIM: \"🗓️\",\n    DELIM + r\"card_index\" + DELIM: \"📇\",\n    DELIM + r\"chart_with_upwards_trend\" + DELIM: \"📈\",\n    DELIM + r\"chart_with_downwards_trend\" + DELIM: \"📉\",\n    DELIM + r\"bar_chart\" + DELIM: \"📊\",\n    DELIM + r\"clipboard\" + DELIM: \"📋\",\n    DELIM + r\"pushpin\" + DELIM: \"📌\",\n    DELIM + r\"round_pushpin\" + DELIM: \"📍\",\n    DELIM + r\"paperclip\" + DELIM: \"📎\",\n    DELIM + r\"paperclips\" + DELIM: \"🖇️\",\n    DELIM + r\"straight_ruler\" + DELIM: \"📏\",\n    DELIM + r\"triangular_ruler\" + DELIM: \"📐\",\n    DELIM + r\"scissors\" + DELIM: \"✂️\",\n    DELIM + r\"card_file_box\" + DELIM: \"🗃️\",\n    DELIM + r\"file_cabinet\" + DELIM: \"🗄️\",\n    DELIM + r\"wastebasket\" + DELIM: \"🗑️\",\n    #\n    # Lock\n    #\n    DELIM + r\"lock\" + DELIM: \"🔒\",\n    DELIM + r\"unlock\" + DELIM: \"🔓\",\n    DELIM + r\"lock_with_ink_pen\" + DELIM: \"🔏\",\n    DELIM + r\"closed_lock_with_key\" + DELIM: \"🔐\",\n    DELIM + r\"key\" + DELIM: \"🔑\",\n    DELIM + r\"old_key\" + DELIM: \"🗝️\",\n    #\n    # Tool\n    #\n    DELIM + r\"hammer\" + DELIM: \"🔨\",\n    DELIM + r\"axe\" + DELIM: \"🪓\",\n    DELIM + r\"pick\" + DELIM: \"⛏️\",\n    DELIM + r\"hammer_and_pick\" + DELIM: \"⚒️\",\n    DELIM + r\"hammer_and_wrench\" + DELIM: \"🛠️\",\n    DELIM + r\"dagger\" + DELIM: \"🗡️\",\n    DELIM + r\"crossed_swords\" + DELIM: \"⚔️\",\n    DELIM + r\"bomb\" + DELIM: \"💣\",\n    DELIM + r\"boomerang\" + DELIM: \"🪃\",\n    DELIM + r\"bow_and_arrow\" + DELIM: \"🏹\",\n    DELIM + r\"shield\" + DELIM: \"🛡️\",\n    DELIM + r\"carpentry_saw\" + DELIM: \"🪚\",\n    DELIM + r\"wrench\" + DELIM: \"🔧\",\n    DELIM + r\"screwdriver\" + DELIM: \"🪛\",\n    DELIM + r\"nut_and_bolt\" + DELIM: \"🔩\",\n    DELIM + r\"gear\" + DELIM: \"⚙️\",\n    DELIM + r\"clamp\" + DELIM: \"🗜️\",\n    DELIM + r\"balance_scale\" + DELIM: \"⚖️\",\n    DELIM + r\"probing_cane\" + DELIM: \"🦯\",\n    DELIM + r\"link\" + DELIM: \"🔗\",\n    DELIM + r\"chains\" + DELIM: \"⛓️\",\n    DELIM + r\"hook\" + DELIM: \"🪝\",\n    DELIM + r\"toolbox\" + DELIM: \"🧰\",\n    DELIM + r\"magnet\" + DELIM: \"🧲\",\n    DELIM + r\"ladder\" + DELIM: \"🪜\",\n    #\n    # Science\n    #\n    DELIM + r\"alembic\" + DELIM: \"⚗️\",\n    DELIM + r\"test_tube\" + DELIM: \"🧪\",\n    DELIM + r\"petri_dish\" + DELIM: \"🧫\",\n    DELIM + r\"dna\" + DELIM: \"🧬\",\n    DELIM + r\"microscope\" + DELIM: \"🔬\",\n    DELIM + r\"telescope\" + DELIM: \"🔭\",\n    DELIM + r\"satellite\" + DELIM: \"📡\",\n    #\n    # Medical\n    #\n    DELIM + r\"syringe\" + DELIM: \"💉\",\n    DELIM + r\"drop_of_blood\" + DELIM: \"🩸\",\n    DELIM + r\"pill\" + DELIM: \"💊\",\n    DELIM + r\"adhesive_bandage\" + DELIM: \"🩹\",\n    DELIM + r\"stethoscope\" + DELIM: \"🩺\",\n    #\n    # Household\n    #\n    DELIM + r\"door\" + DELIM: \"🚪\",\n    DELIM + r\"elevator\" + DELIM: \"🛗\",\n    DELIM + r\"mirror\" + DELIM: \"🪞\",\n    DELIM + r\"window\" + DELIM: \"🪟\",\n    DELIM + r\"bed\" + DELIM: \"🛏️\",\n    DELIM + r\"couch_and_lamp\" + DELIM: \"🛋️\",\n    DELIM + r\"chair\" + DELIM: \"🪑\",\n    DELIM + r\"toilet\" + DELIM: \"🚽\",\n    DELIM + r\"plunger\" + DELIM: \"🪠\",\n    DELIM + r\"shower\" + DELIM: \"🚿\",\n    DELIM + r\"bathtub\" + DELIM: \"🛁\",\n    DELIM + r\"mouse_trap\" + DELIM: \"🪤\",\n    DELIM + r\"razor\" + DELIM: \"🪒\",\n    DELIM + r\"lotion_bottle\" + DELIM: \"🧴\",\n    DELIM + r\"safety_pin\" + DELIM: \"🧷\",\n    DELIM + r\"broom\" + DELIM: \"🧹\",\n    DELIM + r\"basket\" + DELIM: \"🧺\",\n    DELIM + r\"roll_of_paper\" + DELIM: \"🧻\",\n    DELIM + r\"bucket\" + DELIM: \"🪣\",\n    DELIM + r\"soap\" + DELIM: \"🧼\",\n    DELIM + r\"toothbrush\" + DELIM: \"🪥\",\n    DELIM + r\"sponge\" + DELIM: \"🧽\",\n    DELIM + r\"fire_extinguisher\" + DELIM: \"🧯\",\n    DELIM + r\"shopping_cart\" + DELIM: \"🛒\",\n    #\n    # Other Object\n    #\n    DELIM + r\"smoking\" + DELIM: \"🚬\",\n    DELIM + r\"coffin\" + DELIM: \"⚰️\",\n    DELIM + r\"headstone\" + DELIM: \"🪦\",\n    DELIM + r\"funeral_urn\" + DELIM: \"⚱️\",\n    DELIM + r\"nazar_amulet\" + DELIM: \"🧿\",\n    DELIM + r\"moyai\" + DELIM: \"🗿\",\n    DELIM + r\"placard\" + DELIM: \"🪧\",\n    #\n    # Transport Sign\n    #\n    DELIM + r\"atm\" + DELIM: \"🏧\",\n    DELIM + r\"put_litter_in_its_place\" + DELIM: \"🚮\",\n    DELIM + r\"potable_water\" + DELIM: \"🚰\",\n    DELIM + r\"wheelchair\" + DELIM: \"♿\",\n    DELIM + r\"mens\" + DELIM: \"🚹\",\n    DELIM + r\"womens\" + DELIM: \"🚺\",\n    DELIM + r\"restroom\" + DELIM: \"🚻\",\n    DELIM + r\"baby_symbol\" + DELIM: \"🚼\",\n    DELIM + r\"wc\" + DELIM: \"🚾\",\n    DELIM + r\"passport_control\" + DELIM: \"🛂\",\n    DELIM + r\"customs\" + DELIM: \"🛃\",\n    DELIM + r\"baggage_claim\" + DELIM: \"🛄\",\n    DELIM + r\"left_luggage\" + DELIM: \"🛅\",\n    #\n    # Warning\n    #\n    DELIM + r\"warning\" + DELIM: \"⚠️\",\n    DELIM + r\"children_crossing\" + DELIM: \"🚸\",\n    DELIM + r\"no_entry\" + DELIM: \"⛔\",\n    DELIM + r\"no_entry_sign\" + DELIM: \"🚫\",\n    DELIM + r\"no_bicycles\" + DELIM: \"🚳\",\n    DELIM + r\"no_smoking\" + DELIM: \"🚭\",\n    DELIM + r\"do_not_litter\" + DELIM: \"🚯\",\n    DELIM + r\"non-potable_water\" + DELIM: \"🚱\",\n    DELIM + r\"no_pedestrians\" + DELIM: \"🚷\",\n    DELIM + r\"no_mobile_phones\" + DELIM: \"📵\",\n    DELIM + r\"underage\" + DELIM: \"🔞\",\n    DELIM + r\"radioactive\" + DELIM: \"☢️\",\n    DELIM + r\"biohazard\" + DELIM: \"☣️\",\n    #\n    # Arrow\n    #\n    DELIM + r\"arrow_up\" + DELIM: \"⬆️\",\n    DELIM + r\"arrow_upper_right\" + DELIM: \"↗️\",\n    DELIM + r\"arrow_right\" + DELIM: \"➡️\",\n    DELIM + r\"arrow_lower_right\" + DELIM: \"↘️\",\n    DELIM + r\"arrow_down\" + DELIM: \"⬇️\",\n    DELIM + r\"arrow_lower_left\" + DELIM: \"↙️\",\n    DELIM + r\"arrow_left\" + DELIM: \"⬅️\",\n    DELIM + r\"arrow_upper_left\" + DELIM: \"↖️\",\n    DELIM + r\"arrow_up_down\" + DELIM: \"↕️\",\n    DELIM + r\"left_right_arrow\" + DELIM: \"↔️\",\n    DELIM + r\"leftwards_arrow_with_hook\" + DELIM: \"↩️\",\n    DELIM + r\"arrow_right_hook\" + DELIM: \"↪️\",\n    DELIM + r\"arrow_heading_up\" + DELIM: \"⤴️\",\n    DELIM + r\"arrow_heading_down\" + DELIM: \"⤵️\",\n    DELIM + r\"arrows_clockwise\" + DELIM: \"🔃\",\n    DELIM + r\"arrows_counterclockwise\" + DELIM: \"🔄\",\n    DELIM + r\"back\" + DELIM: \"🔙\",\n    DELIM + r\"end\" + DELIM: \"🔚\",\n    DELIM + r\"on\" + DELIM: \"🔛\",\n    DELIM + r\"soon\" + DELIM: \"🔜\",\n    DELIM + r\"top\" + DELIM: \"🔝\",\n    #\n    # Religion\n    #\n    DELIM + r\"place_of_worship\" + DELIM: \"🛐\",\n    DELIM + r\"atom_symbol\" + DELIM: \"⚛️\",\n    DELIM + r\"om\" + DELIM: \"🕉️\",\n    DELIM + r\"star_of_david\" + DELIM: \"✡️\",\n    DELIM + r\"wheel_of_dharma\" + DELIM: \"☸️\",\n    DELIM + r\"yin_yang\" + DELIM: \"☯️\",\n    DELIM + r\"latin_cross\" + DELIM: \"✝️\",\n    DELIM + r\"orthodox_cross\" + DELIM: \"☦️\",\n    DELIM + r\"star_and_crescent\" + DELIM: \"☪️\",\n    DELIM + r\"peace_symbol\" + DELIM: \"☮️\",\n    DELIM + r\"menorah\" + DELIM: \"🕎\",\n    DELIM + r\"six_pointed_star\" + DELIM: \"🔯\",\n    #\n    # Zodiac\n    #\n    DELIM + r\"aries\" + DELIM: \"♈\",\n    DELIM + r\"taurus\" + DELIM: \"♉\",\n    DELIM + r\"gemini\" + DELIM: \"♊\",\n    DELIM + r\"cancer\" + DELIM: \"♋\",\n    DELIM + r\"leo\" + DELIM: \"♌\",\n    DELIM + r\"virgo\" + DELIM: \"♍\",\n    DELIM + r\"libra\" + DELIM: \"♎\",\n    DELIM + r\"scorpius\" + DELIM: \"♏\",\n    DELIM + r\"sagittarius\" + DELIM: \"♐\",\n    DELIM + r\"capricorn\" + DELIM: \"♑\",\n    DELIM + r\"aquarius\" + DELIM: \"♒\",\n    DELIM + r\"pisces\" + DELIM: \"♓\",\n    DELIM + r\"ophiuchus\" + DELIM: \"⛎\",\n    #\n    # Av Symbol\n    #\n    DELIM + r\"twisted_rightwards_arrows\" + DELIM: \"🔀\",\n    DELIM + r\"repeat\" + DELIM: \"🔁\",\n    DELIM + r\"repeat_one\" + DELIM: \"🔂\",\n    DELIM + r\"arrow_forward\" + DELIM: \"▶️\",\n    DELIM + r\"fast_forward\" + DELIM: \"⏩\",\n    DELIM + r\"next_track_button\" + DELIM: \"⏭️\",\n    DELIM + r\"play_or_pause_button\" + DELIM: \"⏯️\",\n    DELIM + r\"arrow_backward\" + DELIM: \"◀️\",\n    DELIM + r\"rewind\" + DELIM: \"⏪\",\n    DELIM + r\"previous_track_button\" + DELIM: \"⏮️\",\n    DELIM + r\"arrow_up_small\" + DELIM: \"🔼\",\n    DELIM + r\"arrow_double_up\" + DELIM: \"⏫\",\n    DELIM + r\"arrow_down_small\" + DELIM: \"🔽\",\n    DELIM + r\"arrow_double_down\" + DELIM: \"⏬\",\n    DELIM + r\"pause_button\" + DELIM: \"⏸️\",\n    DELIM + r\"stop_button\" + DELIM: \"⏹️\",\n    DELIM + r\"record_button\" + DELIM: \"⏺️\",\n    DELIM + r\"eject_button\" + DELIM: \"⏏️\",\n    DELIM + r\"cinema\" + DELIM: \"🎦\",\n    DELIM + r\"low_brightness\" + DELIM: \"🔅\",\n    DELIM + r\"high_brightness\" + DELIM: \"🔆\",\n    DELIM + r\"signal_strength\" + DELIM: \"📶\",\n    DELIM + r\"vibration_mode\" + DELIM: \"📳\",\n    DELIM + r\"mobile_phone_off\" + DELIM: \"📴\",\n    #\n    # Gender\n    #\n    DELIM + r\"female_sign\" + DELIM: \"♀️\",\n    DELIM + r\"male_sign\" + DELIM: \"♂️\",\n    DELIM + r\"transgender_symbol\" + DELIM: \"⚧️\",\n    #\n    # Math\n    #\n    DELIM + r\"heavy_multiplication_x\" + DELIM: \"✖️\",\n    DELIM + r\"heavy_plus_sign\" + DELIM: \"➕\",  # noqa: RUF001\n    DELIM + r\"heavy_minus_sign\" + DELIM: \"➖\",  # noqa: RUF001\n    DELIM + r\"heavy_division_sign\" + DELIM: \"➗\",\n    DELIM + r\"infinity\" + DELIM: \"♾️\",\n    #\n    # Punctuation\n    #\n    DELIM + r\"bangbang\" + DELIM: \"‼️\",\n    DELIM + r\"interrobang\" + DELIM: \"⁉️\",\n    DELIM + r\"question\" + DELIM: \"❓\",\n    DELIM + r\"grey_question\" + DELIM: \"❔\",\n    DELIM + r\"grey_exclamation\" + DELIM: \"❕\",\n    DELIM + r\"(heavy_exclamation_mark|exclamation)\" + DELIM: \"❗\",\n    DELIM + r\"wavy_dash\" + DELIM: \"〰️\",\n    #\n    # Currency\n    #\n    DELIM + r\"currency_exchange\" + DELIM: \"💱\",\n    DELIM + r\"heavy_dollar_sign\" + DELIM: \"💲\",\n    #\n    # Other Symbol\n    #\n    DELIM + r\"medical_symbol\" + DELIM: \"⚕️\",\n    DELIM + r\"recycle\" + DELIM: \"♻️\",\n    DELIM + r\"fleur_de_lis\" + DELIM: \"⚜️\",\n    DELIM + r\"trident\" + DELIM: \"🔱\",\n    DELIM + r\"name_badge\" + DELIM: \"📛\",\n    DELIM + r\"beginner\" + DELIM: \"🔰\",\n    DELIM + r\"o\" + DELIM: \"⭕\",\n    DELIM + r\"white_check_mark\" + DELIM: \"✅\",\n    DELIM + r\"ballot_box_with_check\" + DELIM: \"☑️\",\n    DELIM + r\"heavy_check_mark\" + DELIM: \"✔️\",\n    DELIM + r\"x\" + DELIM: \"❌\",\n    DELIM + r\"negative_squared_cross_mark\" + DELIM: \"❎\",\n    DELIM + r\"curly_loop\" + DELIM: \"➰\",\n    DELIM + r\"loop\" + DELIM: \"➿\",\n    DELIM + r\"part_alternation_mark\" + DELIM: \"〽️\",\n    DELIM + r\"eight_spoked_asterisk\" + DELIM: \"✳️\",\n    DELIM + r\"eight_pointed_black_star\" + DELIM: \"✴️\",\n    DELIM + r\"sparkle\" + DELIM: \"❇️\",\n    DELIM + r\"copyright\" + DELIM: \"©️\",\n    DELIM + r\"registered\" + DELIM: \"®️\",\n    DELIM + r\"tm\" + DELIM: \"™️\",\n    #\n    # Keycap\n    #\n    DELIM + r\"hash\" + DELIM: \"#️⃣\",\n    DELIM + r\"asterisk\" + DELIM: \"*️⃣\",\n    DELIM + r\"zero\" + DELIM: \"0️⃣\",\n    DELIM + r\"one\" + DELIM: \"1️⃣\",\n    DELIM + r\"two\" + DELIM: \"2️⃣\",\n    DELIM + r\"three\" + DELIM: \"3️⃣\",\n    DELIM + r\"four\" + DELIM: \"4️⃣\",\n    DELIM + r\"five\" + DELIM: \"5️⃣\",\n    DELIM + r\"six\" + DELIM: \"6️⃣\",\n    DELIM + r\"seven\" + DELIM: \"7️⃣\",\n    DELIM + r\"eight\" + DELIM: \"8️⃣\",\n    DELIM + r\"nine\" + DELIM: \"9️⃣\",\n    DELIM + r\"keycap_ten\" + DELIM: \"🔟\",\n    #\n    # Alphanum\n    #\n    DELIM + r\"capital_abcd\" + DELIM: \"🔠\",\n    DELIM + r\"abcd\" + DELIM: \"🔡\",\n    DELIM + r\"1234\" + DELIM: \"🔢\",\n    DELIM + r\"symbols\" + DELIM: \"🔣\",\n    DELIM + r\"abc\" + DELIM: \"🔤\",\n    DELIM + r\"a\" + DELIM: \"🅰️\",\n    DELIM + r\"ab\" + DELIM: \"🆎\",\n    DELIM + r\"b\" + DELIM: \"🅱️\",\n    DELIM + r\"cl\" + DELIM: \"🆑\",\n    DELIM + r\"cool\" + DELIM: \"🆒\",\n    DELIM + r\"free\" + DELIM: \"🆓\",\n    DELIM + r\"information_source\" + DELIM: \"ℹ️\",  # noqa: RUF001\n    DELIM + r\"id\" + DELIM: \"🆔\",\n    DELIM + r\"m\" + DELIM: \"Ⓜ️\",\n    DELIM + r\"new\" + DELIM: \"🆕\",\n    DELIM + r\"ng\" + DELIM: \"🆖\",\n    DELIM + r\"o2\" + DELIM: \"🅾️\",\n    DELIM + r\"ok\" + DELIM: \"🆗\",\n    DELIM + r\"parking\" + DELIM: \"🅿️\",\n    DELIM + r\"sos\" + DELIM: \"🆘\",\n    DELIM + r\"up\" + DELIM: \"🆙\",\n    DELIM + r\"vs\" + DELIM: \"🆚\",\n    DELIM + r\"koko\" + DELIM: \"🈁\",\n    DELIM + r\"sa\" + DELIM: \"🈂️\",\n    DELIM + r\"u6708\" + DELIM: \"🈷️\",\n    DELIM + r\"u6709\" + DELIM: \"🈶\",\n    DELIM + r\"u6307\" + DELIM: \"🈯\",\n    DELIM + r\"ideograph_advantage\" + DELIM: \"🉐\",\n    DELIM + r\"u5272\" + DELIM: \"🈹\",\n    DELIM + r\"u7121\" + DELIM: \"🈚\",\n    DELIM + r\"u7981\" + DELIM: \"🈲\",\n    DELIM + r\"accept\" + DELIM: \"🉑\",\n    DELIM + r\"u7533\" + DELIM: \"🈸\",\n    DELIM + r\"u5408\" + DELIM: \"🈴\",\n    DELIM + r\"u7a7a\" + DELIM: \"🈳\",\n    DELIM + r\"congratulations\" + DELIM: \"㊗️\",\n    DELIM + r\"secret\" + DELIM: \"㊙️\",\n    DELIM + r\"u55b6\" + DELIM: \"🈺\",\n    DELIM + r\"u6e80\" + DELIM: \"🈵\",\n    #\n    # Geometric\n    #\n    DELIM + r\"red_circle\" + DELIM: \"🔴\",\n    DELIM + r\"orange_circle\" + DELIM: \"🟠\",\n    DELIM + r\"yellow_circle\" + DELIM: \"🟡\",\n    DELIM + r\"green_circle\" + DELIM: \"🟢\",\n    DELIM + r\"large_blue_circle\" + DELIM: \"🔵\",\n    DELIM + r\"purple_circle\" + DELIM: \"🟣\",\n    DELIM + r\"brown_circle\" + DELIM: \"🟤\",\n    DELIM + r\"black_circle\" + DELIM: \"⚫\",\n    DELIM + r\"white_circle\" + DELIM: \"⚪\",\n    DELIM + r\"red_square\" + DELIM: \"🟥\",\n    DELIM + r\"orange_square\" + DELIM: \"🟧\",\n    DELIM + r\"yellow_square\" + DELIM: \"🟨\",\n    DELIM + r\"green_square\" + DELIM: \"🟩\",\n    DELIM + r\"blue_square\" + DELIM: \"🟦\",\n    DELIM + r\"purple_square\" + DELIM: \"🟪\",\n    DELIM + r\"brown_square\" + DELIM: \"🟫\",\n    DELIM + r\"black_large_square\" + DELIM: \"⬛\",\n    DELIM + r\"white_large_square\" + DELIM: \"⬜\",\n    DELIM + r\"black_medium_square\" + DELIM: \"◼️\",\n    DELIM + r\"white_medium_square\" + DELIM: \"◻️\",\n    DELIM + r\"black_medium_small_square\" + DELIM: \"◾\",\n    DELIM + r\"white_medium_small_square\" + DELIM: \"◽\",\n    DELIM + r\"black_small_square\" + DELIM: \"▪️\",\n    DELIM + r\"white_small_square\" + DELIM: \"▫️\",\n    DELIM + r\"large_orange_diamond\" + DELIM: \"🔶\",\n    DELIM + r\"large_blue_diamond\" + DELIM: \"🔷\",\n    DELIM + r\"small_orange_diamond\" + DELIM: \"🔸\",\n    DELIM + r\"small_blue_diamond\" + DELIM: \"🔹\",\n    DELIM + r\"small_red_triangle\" + DELIM: \"🔺\",\n    DELIM + r\"small_red_triangle_down\" + DELIM: \"🔻\",\n    DELIM + r\"diamond_shape_with_a_dot_inside\" + DELIM: \"💠\",\n    DELIM + r\"radio_button\" + DELIM: \"🔘\",\n    DELIM + r\"white_square_button\" + DELIM: \"🔳\",\n    DELIM + r\"black_square_button\" + DELIM: \"🔲\",\n    #\n    # Flag\n    #\n    DELIM + r\"checkered_flag\" + DELIM: \"🏁\",\n    DELIM + r\"triangular_flag_on_post\" + DELIM: \"🚩\",\n    DELIM + r\"crossed_flags\" + DELIM: \"🎌\",\n    DELIM + r\"black_flag\" + DELIM: \"🏴\",\n    DELIM + r\"white_flag\" + DELIM: \"🏳️\",\n    DELIM + r\"rainbow_flag\" + DELIM: \"🏳️‍🌈\",\n    DELIM + r\"transgender_flag\" + DELIM: \"🏳️‍⚧️\",\n    DELIM + r\"pirate_flag\" + DELIM: \"🏴‍☠️\",\n    #\n    # Country Flag\n    #\n    DELIM + r\"ascension_island\" + DELIM: \"🇦🇨\",\n    DELIM + r\"andorra\" + DELIM: \"🇦🇩\",\n    DELIM + r\"united_arab_emirates\" + DELIM: \"🇦🇪\",\n    DELIM + r\"afghanistan\" + DELIM: \"🇦🇫\",\n    DELIM + r\"antigua_barbuda\" + DELIM: \"🇦🇬\",\n    DELIM + r\"anguilla\" + DELIM: \"🇦🇮\",\n    DELIM + r\"albania\" + DELIM: \"🇦🇱\",\n    DELIM + r\"armenia\" + DELIM: \"🇦🇲\",\n    DELIM + r\"angola\" + DELIM: \"🇦🇴\",\n    DELIM + r\"antarctica\" + DELIM: \"🇦🇶\",\n    DELIM + r\"argentina\" + DELIM: \"🇦🇷\",\n    DELIM + r\"american_samoa\" + DELIM: \"🇦🇸\",\n    DELIM + r\"austria\" + DELIM: \"🇦🇹\",\n    DELIM + r\"australia\" + DELIM: \"🇦🇺\",\n    DELIM + r\"aruba\" + DELIM: \"🇦🇼\",\n    DELIM + r\"aland_islands\" + DELIM: \"🇦🇽\",\n    DELIM + r\"azerbaijan\" + DELIM: \"🇦🇿\",\n    DELIM + r\"bosnia_herzegovina\" + DELIM: \"🇧🇦\",\n    DELIM + r\"barbados\" + DELIM: \"🇧🇧\",\n    DELIM + r\"bangladesh\" + DELIM: \"🇧🇩\",\n    DELIM + r\"belgium\" + DELIM: \"🇧🇪\",\n    DELIM + r\"burkina_faso\" + DELIM: \"🇧🇫\",\n    DELIM + r\"bulgaria\" + DELIM: \"🇧🇬\",\n    DELIM + r\"bahrain\" + DELIM: \"🇧🇭\",\n    DELIM + r\"burundi\" + DELIM: \"🇧🇮\",\n    DELIM + r\"benin\" + DELIM: \"🇧🇯\",\n    DELIM + r\"st_barthelemy\" + DELIM: \"🇧🇱\",\n    DELIM + r\"bermuda\" + DELIM: \"🇧🇲\",\n    DELIM + r\"brunei\" + DELIM: \"🇧🇳\",\n    DELIM + r\"bolivia\" + DELIM: \"🇧🇴\",\n    DELIM + r\"caribbean_netherlands\" + DELIM: \"🇧🇶\",\n    DELIM + r\"brazil\" + DELIM: \"🇧🇷\",\n    DELIM + r\"bahamas\" + DELIM: \"🇧🇸\",\n    DELIM + r\"bhutan\" + DELIM: \"🇧🇹\",\n    DELIM + r\"bouvet_island\" + DELIM: \"🇧🇻\",\n    DELIM + r\"botswana\" + DELIM: \"🇧🇼\",\n    DELIM + r\"belarus\" + DELIM: \"🇧🇾\",\n    DELIM + r\"belize\" + DELIM: \"🇧🇿\",\n    DELIM + r\"canada\" + DELIM: \"🇨🇦\",\n    DELIM + r\"cocos_islands\" + DELIM: \"🇨🇨\",\n    DELIM + r\"congo_kinshasa\" + DELIM: \"🇨🇩\",\n    DELIM + r\"central_african_republic\" + DELIM: \"🇨🇫\",\n    DELIM + r\"congo_brazzaville\" + DELIM: \"🇨🇬\",\n    DELIM + r\"switzerland\" + DELIM: \"🇨🇭\",\n    DELIM + r\"cote_divoire\" + DELIM: \"🇨🇮\",\n    DELIM + r\"cook_islands\" + DELIM: \"🇨🇰\",\n    DELIM + r\"chile\" + DELIM: \"🇨🇱\",\n    DELIM + r\"cameroon\" + DELIM: \"🇨🇲\",\n    DELIM + r\"cn\" + DELIM: \"🇨🇳\",\n    DELIM + r\"colombia\" + DELIM: \"🇨🇴\",\n    DELIM + r\"clipperton_island\" + DELIM: \"🇨🇵\",\n    DELIM + r\"costa_rica\" + DELIM: \"🇨🇷\",\n    DELIM + r\"cuba\" + DELIM: \"🇨🇺\",\n    DELIM + r\"cape_verde\" + DELIM: \"🇨🇻\",\n    DELIM + r\"curacao\" + DELIM: \"🇨🇼\",\n    DELIM + r\"christmas_island\" + DELIM: \"🇨🇽\",\n    DELIM + r\"cyprus\" + DELIM: \"🇨🇾\",\n    DELIM + r\"czech_republic\" + DELIM: \"🇨🇿\",\n    DELIM + r\"de\" + DELIM: \"🇩🇪\",\n    DELIM + r\"diego_garcia\" + DELIM: \"🇩🇬\",\n    DELIM + r\"djibouti\" + DELIM: \"🇩🇯\",\n    DELIM + r\"denmark\" + DELIM: \"🇩🇰\",\n    DELIM + r\"dominica\" + DELIM: \"🇩🇲\",\n    DELIM + r\"dominican_republic\" + DELIM: \"🇩🇴\",\n    DELIM + r\"algeria\" + DELIM: \"🇩🇿\",\n    DELIM + r\"ceuta_melilla\" + DELIM: \"🇪🇦\",\n    DELIM + r\"ecuador\" + DELIM: \"🇪🇨\",\n    DELIM + r\"estonia\" + DELIM: \"🇪🇪\",\n    DELIM + r\"egypt\" + DELIM: \"🇪🇬\",\n    DELIM + r\"western_sahara\" + DELIM: \"🇪🇭\",\n    DELIM + r\"eritrea\" + DELIM: \"🇪🇷\",\n    DELIM + r\"es\" + DELIM: \"🇪🇸\",\n    DELIM + r\"ethiopia\" + DELIM: \"🇪🇹\",\n    DELIM + r\"(eu|european_union)\" + DELIM: \"🇪🇺\",\n    DELIM + r\"finland\" + DELIM: \"🇫🇮\",\n    DELIM + r\"fiji\" + DELIM: \"🇫🇯\",\n    DELIM + r\"falkland_islands\" + DELIM: \"🇫🇰\",\n    DELIM + r\"micronesia\" + DELIM: \"🇫🇲\",\n    DELIM + r\"faroe_islands\" + DELIM: \"🇫🇴\",\n    DELIM + r\"fr\" + DELIM: \"🇫🇷\",\n    DELIM + r\"gabon\" + DELIM: \"🇬🇦\",\n    DELIM + r\"(uk|gb)\" + DELIM: \"🇬🇧\",\n    DELIM + r\"grenada\" + DELIM: \"🇬🇩\",\n    DELIM + r\"georgia\" + DELIM: \"🇬🇪\",\n    DELIM + r\"french_guiana\" + DELIM: \"🇬🇫\",\n    DELIM + r\"guernsey\" + DELIM: \"🇬🇬\",\n    DELIM + r\"ghana\" + DELIM: \"🇬🇭\",\n    DELIM + r\"gibraltar\" + DELIM: \"🇬🇮\",\n    DELIM + r\"greenland\" + DELIM: \"🇬🇱\",\n    DELIM + r\"gambia\" + DELIM: \"🇬🇲\",\n    DELIM + r\"guinea\" + DELIM: \"🇬🇳\",\n    DELIM + r\"guadeloupe\" + DELIM: \"🇬🇵\",\n    DELIM + r\"equatorial_guinea\" + DELIM: \"🇬🇶\",\n    DELIM + r\"greece\" + DELIM: \"🇬🇷\",\n    DELIM + r\"south_georgia_south_sandwich_islands\" + DELIM: \"🇬🇸\",\n    DELIM + r\"guatemala\" + DELIM: \"🇬🇹\",\n    DELIM + r\"guam\" + DELIM: \"🇬🇺\",\n    DELIM + r\"guinea_bissau\" + DELIM: \"🇬🇼\",\n    DELIM + r\"guyana\" + DELIM: \"🇬🇾\",\n    DELIM + r\"hong_kong\" + DELIM: \"🇭🇰\",\n    DELIM + r\"heard_mcdonald_islands\" + DELIM: \"🇭🇲\",\n    DELIM + r\"honduras\" + DELIM: \"🇭🇳\",\n    DELIM + r\"croatia\" + DELIM: \"🇭🇷\",\n    DELIM + r\"haiti\" + DELIM: \"🇭🇹\",\n    DELIM + r\"hungary\" + DELIM: \"🇭🇺\",\n    DELIM + r\"canary_islands\" + DELIM: \"🇮🇨\",\n    DELIM + r\"indonesia\" + DELIM: \"🇮🇩\",\n    DELIM + r\"ireland\" + DELIM: \"🇮🇪\",\n    DELIM + r\"israel\" + DELIM: \"🇮🇱\",\n    DELIM + r\"isle_of_man\" + DELIM: \"🇮🇲\",\n    DELIM + r\"india\" + DELIM: \"🇮🇳\",\n    DELIM + r\"british_indian_ocean_territory\" + DELIM: \"🇮🇴\",\n    DELIM + r\"iraq\" + DELIM: \"🇮🇶\",\n    DELIM + r\"iran\" + DELIM: \"🇮🇷\",\n    DELIM + r\"iceland\" + DELIM: \"🇮🇸\",\n    DELIM + r\"it\" + DELIM: \"🇮🇹\",\n    DELIM + r\"jersey\" + DELIM: \"🇯🇪\",\n    DELIM + r\"jamaica\" + DELIM: \"🇯🇲\",\n    DELIM + r\"jordan\" + DELIM: \"🇯🇴\",\n    DELIM + r\"jp\" + DELIM: \"🇯🇵\",\n    DELIM + r\"kenya\" + DELIM: \"🇰🇪\",\n    DELIM + r\"kyrgyzstan\" + DELIM: \"🇰🇬\",\n    DELIM + r\"cambodia\" + DELIM: \"🇰🇭\",\n    DELIM + r\"kiribati\" + DELIM: \"🇰🇮\",\n    DELIM + r\"comoros\" + DELIM: \"🇰🇲\",\n    DELIM + r\"st_kitts_nevis\" + DELIM: \"🇰🇳\",\n    DELIM + r\"north_korea\" + DELIM: \"🇰🇵\",\n    DELIM + r\"kr\" + DELIM: \"🇰🇷\",\n    DELIM + r\"kuwait\" + DELIM: \"🇰🇼\",\n    DELIM + r\"cayman_islands\" + DELIM: \"🇰🇾\",\n    DELIM + r\"kazakhstan\" + DELIM: \"🇰🇿\",\n    DELIM + r\"laos\" + DELIM: \"🇱🇦\",\n    DELIM + r\"lebanon\" + DELIM: \"🇱🇧\",\n    DELIM + r\"st_lucia\" + DELIM: \"🇱🇨\",\n    DELIM + r\"liechtenstein\" + DELIM: \"🇱🇮\",\n    DELIM + r\"sri_lanka\" + DELIM: \"🇱🇰\",\n    DELIM + r\"liberia\" + DELIM: \"🇱🇷\",\n    DELIM + r\"lesotho\" + DELIM: \"🇱🇸\",\n    DELIM + r\"lithuania\" + DELIM: \"🇱🇹\",\n    DELIM + r\"luxembourg\" + DELIM: \"🇱🇺\",\n    DELIM + r\"latvia\" + DELIM: \"🇱🇻\",\n    DELIM + r\"libya\" + DELIM: \"🇱🇾\",\n    DELIM + r\"morocco\" + DELIM: \"🇲🇦\",\n    DELIM + r\"monaco\" + DELIM: \"🇲🇨\",\n    DELIM + r\"moldova\" + DELIM: \"🇲🇩\",\n    DELIM + r\"montenegro\" + DELIM: \"🇲🇪\",\n    DELIM + r\"st_martin\" + DELIM: \"🇲🇫\",\n    DELIM + r\"madagascar\" + DELIM: \"🇲🇬\",\n    DELIM + r\"marshall_islands\" + DELIM: \"🇲🇭\",\n    DELIM + r\"macedonia\" + DELIM: \"🇲🇰\",\n    DELIM + r\"mali\" + DELIM: \"🇲🇱\",\n    DELIM + r\"myanmar\" + DELIM: \"🇲🇲\",\n    DELIM + r\"mongolia\" + DELIM: \"🇲🇳\",\n    DELIM + r\"macau\" + DELIM: \"🇲🇴\",\n    DELIM + r\"northern_mariana_islands\" + DELIM: \"🇲🇵\",\n    DELIM + r\"martinique\" + DELIM: \"🇲🇶\",\n    DELIM + r\"mauritania\" + DELIM: \"🇲🇷\",\n    DELIM + r\"montserrat\" + DELIM: \"🇲🇸\",\n    DELIM + r\"malta\" + DELIM: \"🇲🇹\",\n    DELIM + r\"mauritius\" + DELIM: \"🇲🇺\",\n    DELIM + r\"maldives\" + DELIM: \"🇲🇻\",\n    DELIM + r\"malawi\" + DELIM: \"🇲🇼\",\n    DELIM + r\"mexico\" + DELIM: \"🇲🇽\",\n    DELIM + r\"malaysia\" + DELIM: \"🇲🇾\",\n    DELIM + r\"mozambique\" + DELIM: \"🇲🇿\",\n    DELIM + r\"namibia\" + DELIM: \"🇳🇦\",\n    DELIM + r\"new_caledonia\" + DELIM: \"🇳🇨\",\n    DELIM + r\"niger\" + DELIM: \"🇳🇪\",\n    DELIM + r\"norfolk_island\" + DELIM: \"🇳🇫\",\n    DELIM + r\"nigeria\" + DELIM: \"🇳🇬\",\n    DELIM + r\"nicaragua\" + DELIM: \"🇳🇮\",\n    DELIM + r\"netherlands\" + DELIM: \"🇳🇱\",\n    DELIM + r\"norway\" + DELIM: \"🇳🇴\",\n    DELIM + r\"nepal\" + DELIM: \"🇳🇵\",\n    DELIM + r\"nauru\" + DELIM: \"🇳🇷\",\n    DELIM + r\"niue\" + DELIM: \"🇳🇺\",\n    DELIM + r\"new_zealand\" + DELIM: \"🇳🇿\",\n    DELIM + r\"oman\" + DELIM: \"🇴🇲\",\n    DELIM + r\"panama\" + DELIM: \"🇵🇦\",\n    DELIM + r\"peru\" + DELIM: \"🇵🇪\",\n    DELIM + r\"french_polynesia\" + DELIM: \"🇵🇫\",\n    DELIM + r\"papua_new_guinea\" + DELIM: \"🇵🇬\",\n    DELIM + r\"philippines\" + DELIM: \"🇵🇭\",\n    DELIM + r\"pakistan\" + DELIM: \"🇵🇰\",\n    DELIM + r\"poland\" + DELIM: \"🇵🇱\",\n    DELIM + r\"st_pierre_miquelon\" + DELIM: \"🇵🇲\",\n    DELIM + r\"pitcairn_islands\" + DELIM: \"🇵🇳\",\n    DELIM + r\"puerto_rico\" + DELIM: \"🇵🇷\",\n    DELIM + r\"palestinian_territories\" + DELIM: \"🇵🇸\",\n    DELIM + r\"portugal\" + DELIM: \"🇵🇹\",\n    DELIM + r\"palau\" + DELIM: \"🇵🇼\",\n    DELIM + r\"paraguay\" + DELIM: \"🇵🇾\",\n    DELIM + r\"qatar\" + DELIM: \"🇶🇦\",\n    DELIM + r\"reunion\" + DELIM: \"🇷🇪\",\n    DELIM + r\"romania\" + DELIM: \"🇷🇴\",\n    DELIM + r\"serbia\" + DELIM: \"🇷🇸\",\n    DELIM + r\"ru\" + DELIM: \"🇷🇺\",\n    DELIM + r\"rwanda\" + DELIM: \"🇷🇼\",\n    DELIM + r\"saudi_arabia\" + DELIM: \"🇸🇦\",\n    DELIM + r\"solomon_islands\" + DELIM: \"🇸🇧\",\n    DELIM + r\"seychelles\" + DELIM: \"🇸🇨\",\n    DELIM + r\"sudan\" + DELIM: \"🇸🇩\",\n    DELIM + r\"sweden\" + DELIM: \"🇸🇪\",\n    DELIM + r\"singapore\" + DELIM: \"🇸🇬\",\n    DELIM + r\"st_helena\" + DELIM: \"🇸🇭\",\n    DELIM + r\"slovenia\" + DELIM: \"🇸🇮\",\n    DELIM + r\"svalbard_jan_mayen\" + DELIM: \"🇸🇯\",\n    DELIM + r\"slovakia\" + DELIM: \"🇸🇰\",\n    DELIM + r\"sierra_leone\" + DELIM: \"🇸🇱\",\n    DELIM + r\"san_marino\" + DELIM: \"🇸🇲\",\n    DELIM + r\"senegal\" + DELIM: \"🇸🇳\",\n    DELIM + r\"somalia\" + DELIM: \"🇸🇴\",\n    DELIM + r\"suriname\" + DELIM: \"🇸🇷\",\n    DELIM + r\"south_sudan\" + DELIM: \"🇸🇸\",\n    DELIM + r\"sao_tome_principe\" + DELIM: \"🇸🇹\",\n    DELIM + r\"el_salvador\" + DELIM: \"🇸🇻\",\n    DELIM + r\"sint_maarten\" + DELIM: \"🇸🇽\",\n    DELIM + r\"syria\" + DELIM: \"🇸🇾\",\n    DELIM + r\"swaziland\" + DELIM: \"🇸🇿\",\n    DELIM + r\"tristan_da_cunha\" + DELIM: \"🇹🇦\",\n    DELIM + r\"turks_caicos_islands\" + DELIM: \"🇹🇨\",\n    DELIM + r\"chad\" + DELIM: \"🇹🇩\",\n    DELIM + r\"french_southern_territories\" + DELIM: \"🇹🇫\",\n    DELIM + r\"togo\" + DELIM: \"🇹🇬\",\n    DELIM + r\"thailand\" + DELIM: \"🇹🇭\",\n    DELIM + r\"tajikistan\" + DELIM: \"🇹🇯\",\n    DELIM + r\"tokelau\" + DELIM: \"🇹🇰\",\n    DELIM + r\"timor_leste\" + DELIM: \"🇹🇱\",\n    DELIM + r\"turkmenistan\" + DELIM: \"🇹🇲\",\n    DELIM + r\"tunisia\" + DELIM: \"🇹🇳\",\n    DELIM + r\"tonga\" + DELIM: \"🇹🇴\",\n    DELIM + r\"tr\" + DELIM: \"🇹🇷\",\n    DELIM + r\"trinidad_tobago\" + DELIM: \"🇹🇹\",\n    DELIM + r\"tuvalu\" + DELIM: \"🇹🇻\",\n    DELIM + r\"taiwan\" + DELIM: \"🇹🇼\",\n    DELIM + r\"tanzania\" + DELIM: \"🇹🇿\",\n    DELIM + r\"ukraine\" + DELIM: \"🇺🇦\",\n    DELIM + r\"uganda\" + DELIM: \"🇺🇬\",\n    DELIM + r\"us_outlying_islands\" + DELIM: \"🇺🇲\",\n    DELIM + r\"united_nations\" + DELIM: \"🇺🇳\",\n    DELIM + r\"us\" + DELIM: \"🇺🇸\",\n    DELIM + r\"uruguay\" + DELIM: \"🇺🇾\",\n    DELIM + r\"uzbekistan\" + DELIM: \"🇺🇿\",\n    DELIM + r\"vatican_city\" + DELIM: \"🇻🇦\",\n    DELIM + r\"st_vincent_grenadines\" + DELIM: \"🇻🇨\",\n    DELIM + r\"venezuela\" + DELIM: \"🇻🇪\",\n    DELIM + r\"british_virgin_islands\" + DELIM: \"🇻🇬\",\n    DELIM + r\"us_virgin_islands\" + DELIM: \"🇻🇮\",\n    DELIM + r\"vietnam\" + DELIM: \"🇻🇳\",\n    DELIM + r\"vanuatu\" + DELIM: \"🇻🇺\",\n    DELIM + r\"wallis_futuna\" + DELIM: \"🇼🇫\",\n    DELIM + r\"samoa\" + DELIM: \"🇼🇸\",\n    DELIM + r\"kosovo\" + DELIM: \"🇽🇰\",\n    DELIM + r\"yemen\" + DELIM: \"🇾🇪\",\n    DELIM + r\"mayotte\" + DELIM: \"🇾🇹\",\n    DELIM + r\"south_africa\" + DELIM: \"🇿🇦\",\n    DELIM + r\"zambia\" + DELIM: \"🇿🇲\",\n    DELIM + r\"zimbabwe\" + DELIM: \"🇿🇼\",\n    #\n    # Subdivision Flag\n    #\n    DELIM + r\"england\" + DELIM: \"🏴󠁧󠁢󠁥󠁮󠁧󠁿\",\n    DELIM + r\"scotland\" + DELIM: \"🏴󠁧󠁢󠁳󠁣󠁴󠁿\",\n    DELIM + r\"wales\" + DELIM: \"🏴󠁧󠁢󠁷󠁬󠁳󠁿\",\n}\n\n# Define our singlton\nEMOJI_COMPILED_MAP = None\n\n\ndef apply_emojis(content):\n    \"\"\"Takes the content and swaps any matched emoji's found with their utf-8\n    encoded mapping.\"\"\"\n\n    global EMOJI_COMPILED_MAP\n\n    if EMOJI_COMPILED_MAP is None:\n        t_start = time.time()\n        # Perform our compilation\n        EMOJI_COMPILED_MAP = re.compile(\n            r\"(\" + \"|\".join(EMOJI_MAP.keys()) + r\")\", re.IGNORECASE\n        )\n        logger.trace(f\"Emoji engine loaded in {time.time() - t_start:.4f}s\")\n\n    try:\n        return EMOJI_COMPILED_MAP.sub(lambda x: EMOJI_MAP[x.group()], content)\n\n    except TypeError:\n        # No change; but force string return\n        return \"\"\n"
  },
  {
    "path": "apprise/exception.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport errno\n\n\nclass AppriseException(Exception):\n    \"\"\"Base Apprise Exception Class.\"\"\"\n\n    def __init__(self, message, error_code=0):\n        super().__init__(message)\n        self.error_code = error_code\n\n\nclass ApprisePluginException(AppriseException):\n    \"\"\"Class object for handling exceptions raised from within a plugin.\"\"\"\n\n    def __init__(self, message, error_code=600):\n        super().__init__(message, error_code=error_code)\n\n\nclass AppriseDiskIOError(AppriseException):\n    \"\"\"Thrown when an disk i/o error occurs.\"\"\"\n\n    def __init__(self, message, error_code=errno.EIO):\n        super().__init__(message, error_code=error_code)\n\n\nclass AppriseInvalidData(AppriseException):\n    \"\"\"Thrown when bad data was passed into an internal function.\"\"\"\n\n    def __init__(self, message, error_code=errno.EINVAL):\n        super().__init__(message, error_code=error_code)\n\n\nclass AppriseFileNotFound(AppriseDiskIOError, FileNotFoundError):\n    \"\"\"Thrown when a persistent write occured in MEMORY mode.\"\"\"\n\n    def __init__(self, message):\n        super().__init__(message, error_code=errno.ENOENT)\n"
  },
  {
    "path": "apprise/i18n/__init__.py",
    "content": ""
  },
  {
    "path": "apprise/i18n/en/LC_MESSAGES/apprise.po",
    "content": "# English translations for apprise.\n# Copyright (C) 2026 Chris Caron\n# This file is distributed under the same license as the apprise project.\n# Chris Caron <lead2gold@gmail.com>, 2026.\n#\nmsgid \"\"\nmsgstr \"\"\n\"Project-Id-Version: apprise 1.9.8\\n\"\n\"Report-Msgid-Bugs-To: lead2gold@gmail.com\\n\"\n\"POT-Creation-Date: 2026-03-08 16:43-0400\\n\"\n\"PO-Revision-Date: 2019-05-24 20:00-0400\\n\"\n\"Last-Translator: Chris Caron <lead2gold@gmail.com>\\n\"\n\"Language: en\\n\"\n\"Language-Team: en <LL@li.org>\\n\"\n\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n\"MIME-Version: 1.0\\n\"\n\"Content-Type: text/plain; charset=utf-8\\n\"\n\"Content-Transfer-Encoding: 8bit\\n\"\n\"Generated-By: Babel 2.18.0\\n\"\n\n#: apprise/attachment/base.py:96 apprise/url.py:141\nmsgid \"Verify SSL\"\nmsgstr \"Verify SSL\"\n\n#: apprise/url.py:151\n#, fuzzy\nmsgid \"Socket Read Timeout\"\nmsgstr \"Server Timeout\"\n\n#: apprise/url.py:165\n#, fuzzy\nmsgid \"Socket Connect Timeout\"\nmsgstr \"Server Timeout\"\n\n#: apprise/attachment/base.py:82\nmsgid \"Cache Age\"\nmsgstr \"\"\n\n#: apprise/attachment/base.py:88\nmsgid \"Forced Mime Type\"\nmsgstr \"\"\n\n#: apprise/attachment/base.py:92\nmsgid \"Forced File Name\"\nmsgstr \"\"\n\n#: apprise/attachment/file.py:41 apprise/config/file.py:41\nmsgid \"Local File\"\nmsgstr \"\"\n\n#: apprise/attachment/http.py:46 apprise/config/http.py:54\nmsgid \"Web Based\"\nmsgstr \"\"\n\n#: apprise/attachment/memory.py:44 apprise/config/memory.py:37\nmsgid \"Memory\"\nmsgstr \"\"\n\n#: apprise/plugins/__init__.py:280\nmsgid \"Schema\"\nmsgstr \"Schema\"\n\n#: apprise/plugins/__init__.py:401\nmsgid \"No dependencies.\"\nmsgstr \"\"\n\n#: apprise/plugins/__init__.py:404\nmsgid \"Packages are required to function.\"\nmsgstr \"\"\n\n#: apprise/plugins/__init__.py:408\nmsgid \"Packages are recommended to improve functionality.\"\nmsgstr \"\"\n\n#: apprise/plugins/africas_talking.py:132\n#, fuzzy\nmsgid \"App User Name\"\nmsgstr \"User Name\"\n\n#: apprise/plugins/africas_talking.py:138 apprise/plugins/brevo.py:112\n#: apprise/plugins/burstsms.py:104 apprise/plugins/clicksend.py:98\n#: apprise/plugins/dot.py:121 apprise/plugins/fcm/__init__.py:143\n#: apprise/plugins/httpsms.py:77 apprise/plugins/join.py:140\n#: apprise/plugins/kavenegar.py:115 apprise/plugins/kumulos.py:87\n#: apprise/plugins/mailgun.py:146 apprise/plugins/messagebird.py:78\n#: apprise/plugins/one_signal.py:113 apprise/plugins/opsgenie.py:236\n#: apprise/plugins/pagerduty.py:131 apprise/plugins/popcorn_notify.py:71\n#: apprise/plugins/prowl.py:121 apprise/plugins/resend.py:107\n#: apprise/plugins/sendgrid.py:116 apprise/plugins/seven.py:75\n#: apprise/plugins/simplepush.py:101 apprise/plugins/smsmanager.py:106\n#: apprise/plugins/smtp2go.py:118 apprise/plugins/sparkpost.py:169\n#: apprise/plugins/splunk.py:165 apprise/plugins/techuluspush.py:97\n#: apprise/plugins/twilio.py:197 apprise/plugins/vapid/__init__.py:152\n#: apprise/plugins/vonage.py:80\nmsgid \"API Key\"\nmsgstr \"API Key\"\n\n#: apprise/plugins/africas_talking.py:145 apprise/plugins/fortysixelks.py:106\n#, fuzzy\nmsgid \"Target Phone\"\nmsgstr \"Target Phone No\"\n\n#: apprise/plugins/africas_talking.py:150 apprise/plugins/aprs.py:187\n#: apprise/plugins/bark.py:159 apprise/plugins/brevo.py:129\n#: apprise/plugins/bulksms.py:137 apprise/plugins/bulkvs.py:110\n#: apprise/plugins/burstsms.py:131 apprise/plugins/clickatell.py:90\n#: apprise/plugins/clicksend.py:112 apprise/plugins/d7networks.py:110\n#: apprise/plugins/dapnet.py:138 apprise/plugins/dingtalk.py:111\n#: apprise/plugins/email/base.py:140 apprise/plugins/fcm/__init__.py:168\n#: apprise/plugins/flock.py:125 apprise/plugins/fortysixelks.py:111\n#: apprise/plugins/httpsms.py:97 apprise/plugins/irc/base.py:149\n#: apprise/plugins/join.py:172 apprise/plugins/kavenegar.py:135\n#: apprise/plugins/line.py:93 apprise/plugins/mailgun.py:157\n#: apprise/plugins/mastodon.py:190 apprise/plugins/matrix.py:262\n#: apprise/plugins/mattermost.py:185 apprise/plugins/messagebird.py:99\n#: apprise/plugins/mqtt.py:174 apprise/plugins/msg91.py:123\n#: apprise/plugins/nextcloud.py:148 apprise/plugins/nextcloudtalk.py:101\n#: apprise/plugins/notifiarr.py:103 apprise/plugins/notificationapi.py:187\n#: apprise/plugins/ntfy.py:241 apprise/plugins/office365.py:148\n#: apprise/plugins/one_signal.py:141 apprise/plugins/plivo.py:114\n#: apprise/plugins/popcorn_notify.py:89 apprise/plugins/pushbullet.py:105\n#: apprise/plugins/pushed.py:107 apprise/plugins/pushover.py:206\n#: apprise/plugins/pushsafer.py:376 apprise/plugins/pushy.py:97\n#: apprise/plugins/reddit.py:170 apprise/plugins/resend.py:124\n#: apprise/plugins/revolt.py:112 apprise/plugins/rocketchat.py:166\n#: apprise/plugins/sendgrid.py:133 apprise/plugins/sendpulse.py:136\n#: apprise/plugins/seven.py:88 apprise/plugins/sfr.py:113\n#: apprise/plugins/signal_api.py:138 apprise/plugins/sinch.py:140\n#: apprise/plugins/slack.py:248 apprise/plugins/smpp.py:128\n#: apprise/plugins/smseagle.py:172 apprise/plugins/smsmanager.py:119\n#: apprise/plugins/sns.py:138 apprise/plugins/telegram.py:359\n#: apprise/plugins/threema.py:115 apprise/plugins/twilio.py:174\n#: apprise/plugins/twist.py:123 apprise/plugins/twitter.py:168\n#: apprise/plugins/vapid/__init__.py:158 apprise/plugins/voipms.py:107\n#: apprise/plugins/vonage.py:108 apprise/plugins/whatsapp.py:126\n#: apprise/plugins/wxpusher.py:139 apprise/plugins/xmpp/base.py:118\n#: apprise/plugins/zulip.py:153\nmsgid \"Targets\"\nmsgstr \"Targets\"\n\n#: apprise/plugins/africas_talking.py:168\n#, fuzzy\nmsgid \"From\"\nmsgstr \"Rooms\"\n\n#: apprise/plugins/africas_talking.py:174 apprise/plugins/bulksms.py:170\n#: apprise/plugins/bulkvs.py:131 apprise/plugins/burstsms.py:166\n#: apprise/plugins/clicksend.py:130 apprise/plugins/d7networks.py:128\n#: apprise/plugins/dapnet.py:167 apprise/plugins/mailgun.py:194\n#: apprise/plugins/mastodon.py:215 apprise/plugins/one_signal.py:161\n#: apprise/plugins/opsgenie.py:288 apprise/plugins/plivo.py:137\n#: apprise/plugins/popcorn_notify.py:104 apprise/plugins/signal_api.py:155\n#: apprise/plugins/smseagle.py:190 apprise/plugins/smsmanager.py:152\n#: apprise/plugins/smtp2go.py:151 apprise/plugins/sparkpost.py:209\n#: apprise/plugins/twitter.py:193\n#, fuzzy\nmsgid \"Batch Mode\"\nmsgstr \"Webhook Mode\"\n\n#: apprise/plugins/africas_talking.py:179\n#, fuzzy\nmsgid \"SMS Mode\"\nmsgstr \"Secure Mode\"\n\n#: apprise/plugins/apprise_api.py:102 apprise/plugins/bark.py:134\n#: apprise/plugins/custom_form.py:121 apprise/plugins/custom_json.py:103\n#: apprise/plugins/custom_xml.py:103 apprise/plugins/emby.py:85\n#: apprise/plugins/enigma2.py:110 apprise/plugins/fluxer.py:159\n#: apprise/plugins/gotify.py:129 apprise/plugins/growl.py:140\n#: apprise/plugins/home_assistant.py:79 apprise/plugins/irc/base.py:117\n#: apprise/plugins/lametric.py:460 apprise/plugins/mastodon.py:168\n#: apprise/plugins/matrix.py:220 apprise/plugins/mattermost.py:151\n#: apprise/plugins/misskey.py:117 apprise/plugins/mqtt.py:147\n#: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74\n#: apprise/plugins/notica.py:126 apprise/plugins/ntfy.py:211\n#: apprise/plugins/parseplatform.py:90 apprise/plugins/pushdeer.py:75\n#: apprise/plugins/pushjet.py:71 apprise/plugins/rocketchat.py:120\n#: apprise/plugins/rsyslog.py:180 apprise/plugins/signal_api.py:97\n#: apprise/plugins/smseagle.py:135 apprise/plugins/synology.py:83\n#: apprise/plugins/workflows.py:125 apprise/plugins/xbmc.py:96\n#: apprise/plugins/xmpp/base.py:96\nmsgid \"Hostname\"\nmsgstr \"Hostname\"\n\n#: apprise/plugins/apprise_api.py:107 apprise/plugins/bark.py:139\n#: apprise/plugins/custom_form.py:126 apprise/plugins/custom_json.py:108\n#: apprise/plugins/custom_xml.py:108 apprise/plugins/email/base.py:129\n#: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:115\n#: apprise/plugins/fluxer.py:163 apprise/plugins/gotify.py:140\n#: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:84\n#: apprise/plugins/irc/base.py:122 apprise/plugins/lametric.py:464\n#: apprise/plugins/mastodon.py:178 apprise/plugins/matrix.py:224\n#: apprise/plugins/mattermost.py:167 apprise/plugins/misskey.py:127\n#: apprise/plugins/mqtt.py:152 apprise/plugins/nextcloud.py:121\n#: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130\n#: apprise/plugins/ntfy.py:215 apprise/plugins/parseplatform.py:95\n#: apprise/plugins/pushdeer.py:79 apprise/plugins/pushjet.py:76\n#: apprise/plugins/rocketchat.py:125 apprise/plugins/rsyslog.py:185\n#: apprise/plugins/signal_api.py:102 apprise/plugins/smpp.py:108\n#: apprise/plugins/smseagle.py:140 apprise/plugins/synology.py:88\n#: apprise/plugins/workflows.py:130 apprise/plugins/xbmc.py:101\n#: apprise/plugins/xmpp/base.py:101\nmsgid \"Port\"\nmsgstr \"Port\"\n\n#: apprise/plugins/apprise_api.py:113 apprise/plugins/bark.py:145\n#: apprise/plugins/bluesky.py:119 apprise/plugins/custom_form.py:132\n#: apprise/plugins/custom_json.py:114 apprise/plugins/custom_xml.py:114\n#: apprise/plugins/emby.py:97 apprise/plugins/enigma2.py:121\n#: apprise/plugins/freemobile.py:78 apprise/plugins/home_assistant.py:90\n#: apprise/plugins/lametric.py:471 apprise/plugins/matrix.py:230\n#: apprise/plugins/nextcloud.py:127 apprise/plugins/nextcloudtalk.py:85\n#: apprise/plugins/notica.py:136 apprise/plugins/ntfy.py:221\n#: apprise/plugins/opsgenie.py:242 apprise/plugins/pushjet.py:88\n#: apprise/plugins/rocketchat.py:131 apprise/plugins/signal_api.py:108\n#: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:94\n#: apprise/plugins/xbmc.py:107\nmsgid \"Username\"\nmsgstr \"Username\"\n\n#: apprise/plugins/apprise_api.py:117 apprise/plugins/aprs.py:172\n#: apprise/plugins/bark.py:149 apprise/plugins/bluesky.py:124\n#: apprise/plugins/bulksms.py:117 apprise/plugins/bulkvs.py:90\n#: apprise/plugins/custom_form.py:136 apprise/plugins/custom_json.py:118\n#: apprise/plugins/custom_xml.py:118 apprise/plugins/dapnet.py:123\n#: apprise/plugins/email/base.py:119 apprise/plugins/emby.py:101\n#: apprise/plugins/enigma2.py:125 apprise/plugins/freemobile.py:83\n#: apprise/plugins/growl.py:151 apprise/plugins/home_assistant.py:94\n#: apprise/plugins/irc/base.py:132 apprise/plugins/matrix.py:234\n#: apprise/plugins/mqtt.py:163 apprise/plugins/nextcloud.py:131\n#: apprise/plugins/nextcloudtalk.py:90 apprise/plugins/notica.py:140\n#: apprise/plugins/ntfy.py:225 apprise/plugins/pushjet.py:92\n#: apprise/plugins/reddit.py:145 apprise/plugins/rocketchat.py:135\n#: apprise/plugins/signal_api.py:112 apprise/plugins/simplepush.py:108\n#: apprise/plugins/smpp.py:97 apprise/plugins/synology.py:98\n#: apprise/plugins/twist.py:101 apprise/plugins/voipms.py:88\n#: apprise/plugins/xbmc.py:111 apprise/plugins/xmpp/base.py:112\nmsgid \"Password\"\nmsgstr \"Password\"\n\n#: apprise/plugins/apprise_api.py:122 apprise/plugins/chanify.py:74\n#: apprise/plugins/dingtalk.py:93 apprise/plugins/feishu.py:80\n#: apprise/plugins/gotify.py:123 apprise/plugins/mattermost.py:157\n#: apprise/plugins/notica.py:119 apprise/plugins/notifiarr.py:91\n#: apprise/plugins/ntfy.py:230 apprise/plugins/pushme.py:62\n#: apprise/plugins/ryver.py:99 apprise/plugins/serverchan.py:70\n#: apprise/plugins/slack.py:294 apprise/plugins/synology.py:103\n#: apprise/plugins/webexteams.py:116 apprise/plugins/zulip.py:136\nmsgid \"Token\"\nmsgstr \"Token\"\n\n#: apprise/plugins/apprise_api.py:136 apprise/plugins/ntfy.py:288\n#: apprise/plugins/opsgenie.py:307 apprise/plugins/pagertree.py:133\n#, fuzzy\nmsgid \"Tags\"\nmsgstr \"Targets\"\n\n#: apprise/plugins/apprise_api.py:140\nmsgid \"Query Method\"\nmsgstr \"\"\n\n#: apprise/plugins/apprise_api.py:154 apprise/plugins/custom_form.py:165\n#: apprise/plugins/custom_json.py:141 apprise/plugins/custom_xml.py:141\n#: apprise/plugins/enigma2.py:153 apprise/plugins/nextcloud.py:181\n#: apprise/plugins/nextcloudtalk.py:122 apprise/plugins/notica.py:156\n#: apprise/plugins/pagertree.py:142 apprise/plugins/synology.py:128\nmsgid \"HTTP Header\"\nmsgstr \"HTTP Header\"\n\n#: apprise/plugins/aprs.py:167 apprise/plugins/bulksms.py:112\n#: apprise/plugins/bulkvs.py:85 apprise/plugins/clicksend.py:93\n#: apprise/plugins/dapnet.py:118 apprise/plugins/email/base.py:115\n#: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:158\n#: apprise/plugins/reddit.py:140 apprise/plugins/sendpulse.py:108\n#: apprise/plugins/smtp2go.py:108 apprise/plugins/sparkpost.py:159\nmsgid \"User Name\"\nmsgstr \"User Name\"\n\n#: apprise/plugins/aprs.py:178 apprise/plugins/aprs.py:199\n#: apprise/plugins/dapnet.py:129 apprise/plugins/dapnet.py:150\n#, fuzzy\nmsgid \"Target Callsign\"\nmsgstr \"Target Emails\"\n\n#: apprise/plugins/aprs.py:204\nmsgid \"Resend Delay\"\nmsgstr \"\"\n\n#: apprise/plugins/aprs.py:211\nmsgid \"Locale\"\nmsgstr \"\"\n\n#: apprise/plugins/bark.py:154 apprise/plugins/fcm/__init__.py:157\n#: apprise/plugins/pushbullet.py:89 apprise/plugins/pushover.py:200\n#: apprise/plugins/pushsafer.py:366 apprise/plugins/pushy.py:85\nmsgid \"Target Device\"\nmsgstr \"Target Device\"\n\n#: apprise/plugins/bark.py:174 apprise/plugins/lametric.py:516\n#: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:223\n#: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110\nmsgid \"Sound\"\nmsgstr \"Sound\"\n\n#: apprise/plugins/bark.py:179\nmsgid \"Level\"\nmsgstr \"\"\n\n#: apprise/plugins/bark.py:184\nmsgid \"Volume\"\nmsgstr \"\"\n\n#: apprise/plugins/bark.py:190 apprise/plugins/ntfy.py:270\n#: apprise/plugins/pagerduty.py:172\nmsgid \"Click\"\nmsgstr \"\"\n\n#: apprise/plugins/bark.py:194 apprise/plugins/pushy.py:114\nmsgid \"Badge\"\nmsgstr \"\"\n\n#: apprise/plugins/bark.py:199\nmsgid \"Category\"\nmsgstr \"\"\n\n#: apprise/plugins/bark.py:203 apprise/plugins/join.py:158\n#: apprise/plugins/pagerduty.py:163\nmsgid \"Group\"\nmsgstr \"Group\"\n\n#: apprise/plugins/bark.py:207 apprise/plugins/dbus.py:225\n#: apprise/plugins/discord.py:196 apprise/plugins/fcm/__init__.py:197\n#: apprise/plugins/flock.py:136 apprise/plugins/fluxer.py:250\n#: apprise/plugins/glib.py:187 apprise/plugins/gnome.py:154\n#: apprise/plugins/growl.py:175 apprise/plugins/join.py:182\n#: apprise/plugins/line.py:108 apprise/plugins/macosx.py:115\n#: apprise/plugins/matrix.py:273 apprise/plugins/mattermost.py:211\n#: apprise/plugins/msteams.py:200 apprise/plugins/notifiarr.py:125\n#: apprise/plugins/ntfy.py:256 apprise/plugins/one_signal.py:155\n#: apprise/plugins/pagerduty.py:191 apprise/plugins/ryver.py:124\n#: apprise/plugins/slack.py:259 apprise/plugins/telegram.py:370\n#: apprise/plugins/vapid/__init__.py:204 apprise/plugins/windows.py:106\n#: apprise/plugins/workflows.py:162 apprise/plugins/xbmc.py:129\nmsgid \"Include Image\"\nmsgstr \"Include Image\"\n\n#: apprise/plugins/bark.py:213 apprise/plugins/mattermost.py:207\n#: apprise/plugins/revolt.py:128\nmsgid \"Icon URL\"\nmsgstr \"\"\n\n#: apprise/plugins/bark.py:217 apprise/plugins/streamlabs.py:119\nmsgid \"Call\"\nmsgstr \"\"\n\n#: apprise/plugins/base.py:192\nmsgid \"Overflow Mode\"\nmsgstr \"Overflow Mode\"\n\n#: apprise/plugins/base.py:207\nmsgid \"Notify Format\"\nmsgstr \"Notify Format\"\n\n#: apprise/plugins/base.py:217\n#, fuzzy\nmsgid \"Interpret Emojis\"\nmsgstr \"Target Emails\"\n\n#: apprise/plugins/base.py:227\nmsgid \"Persistent Storage\"\nmsgstr \"\"\n\n#: apprise/plugins/base.py:237\n#, fuzzy\nmsgid \"Timezone\"\nmsgstr \"Server Timeout\"\n\n#: apprise/plugins/brevo.py:119 apprise/plugins/resend.py:114\n#: apprise/plugins/sendgrid.py:123\n#, fuzzy\nmsgid \"Source Email\"\nmsgstr \"Source JID\"\n\n#: apprise/plugins/brevo.py:124 apprise/plugins/email/base.py:135\n#: apprise/plugins/mailgun.py:152 apprise/plugins/notificationapi.py:172\n#: apprise/plugins/office365.py:143 apprise/plugins/one_signal.py:124\n#: apprise/plugins/popcorn_notify.py:84 apprise/plugins/pushbullet.py:100\n#: apprise/plugins/pushsafer.py:371 apprise/plugins/resend.py:119\n#: apprise/plugins/sendgrid.py:128 apprise/plugins/sendpulse.py:131\n#: apprise/plugins/slack.py:231 apprise/plugins/threema.py:105\nmsgid \"Target Email\"\nmsgstr \"Target Email\"\n\n#: apprise/plugins/brevo.py:143 apprise/plugins/email/base.py:165\n#: apprise/plugins/mailgun.py:186 apprise/plugins/notificationapi.py:218\n#: apprise/plugins/office365.py:162 apprise/plugins/resend.py:138\n#: apprise/plugins/sendgrid.py:147 apprise/plugins/sendpulse.py:152\n#: apprise/plugins/ses.py:206 apprise/plugins/smtp2go.py:143\n#: apprise/plugins/sparkpost.py:201\nmsgid \"Carbon Copy\"\nmsgstr \"\"\n\n#: apprise/plugins/brevo.py:147 apprise/plugins/email/base.py:169\n#: apprise/plugins/mailgun.py:190 apprise/plugins/notificationapi.py:222\n#: apprise/plugins/office365.py:166 apprise/plugins/resend.py:142\n#: apprise/plugins/sendgrid.py:151 apprise/plugins/sendpulse.py:156\n#: apprise/plugins/ses.py:210 apprise/plugins/smtp2go.py:147\n#: apprise/plugins/sparkpost.py:205\nmsgid \"Blind Carbon Copy\"\nmsgstr \"\"\n\n#: apprise/plugins/brevo.py:151 apprise/plugins/ses.py:196\n#, fuzzy\nmsgid \"Reply To Email\"\nmsgstr \"To Email\"\n\n#: apprise/plugins/bulksms.py:123 apprise/plugins/bulkvs.py:103\n#: apprise/plugins/burstsms.py:124 apprise/plugins/clickatell.py:83\n#: apprise/plugins/clicksend.py:105 apprise/plugins/d7networks.py:103\n#: apprise/plugins/dingtalk.py:106 apprise/plugins/httpsms.py:90\n#: apprise/plugins/kavenegar.py:128 apprise/plugins/messagebird.py:92\n#: apprise/plugins/msg91.py:116 apprise/plugins/plivo.py:107\n#: apprise/plugins/popcorn_notify.py:77 apprise/plugins/seven.py:81\n#: apprise/plugins/signal_api.py:124 apprise/plugins/sinch.py:127\n#: apprise/plugins/smpp.py:121 apprise/plugins/smseagle.py:151\n#: apprise/plugins/smsmanager.py:112 apprise/plugins/sns.py:125\n#: apprise/plugins/threema.py:98 apprise/plugins/twilio.py:161\n#: apprise/plugins/voipms.py:100 apprise/plugins/vonage.py:101\n#: apprise/plugins/whatsapp.py:119\nmsgid \"Target Phone No\"\nmsgstr \"Target Phone No\"\n\n#: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142\n#, fuzzy\nmsgid \"Target Group\"\nmsgstr \"Target Topic\"\n\n#: apprise/plugins/bulksms.py:152 apprise/plugins/bulkvs.py:96\n#: apprise/plugins/bulkvs.py:125 apprise/plugins/clickatell.py:78\n#: apprise/plugins/fortysixelks.py:100 apprise/plugins/httpsms.py:83\n#: apprise/plugins/httpsms.py:115 apprise/plugins/signal_api.py:117\n#: apprise/plugins/sinch.py:120 apprise/plugins/smpp.py:114\n#: apprise/plugins/smsmanager.py:137 apprise/plugins/twilio.py:154\n#: apprise/plugins/voipms.py:94 apprise/plugins/vonage.py:94\nmsgid \"From Phone No\"\nmsgstr \"From Phone No\"\n\n#: apprise/plugins/bulksms.py:158\n#, fuzzy\nmsgid \"Route Group\"\nmsgstr \"Group\"\n\n#: apprise/plugins/bulksms.py:165 apprise/plugins/d7networks.py:123\nmsgid \"Unicode Characters\"\nmsgstr \"\"\n\n#: apprise/plugins/burstsms.py:111 apprise/plugins/threema.py:92\n#: apprise/plugins/vonage.py:87\n#, fuzzy\nmsgid \"API Secret\"\nmsgstr \"Application Secret\"\n\n#: apprise/plugins/burstsms.py:118\n#, fuzzy\nmsgid \"Sender ID\"\nmsgstr \"To User ID\"\n\n#: apprise/plugins/burstsms.py:155\nmsgid \"Country\"\nmsgstr \"\"\n\n#: apprise/plugins/burstsms.py:164\nmsgid \"validity\"\nmsgstr \"\"\n\n#: apprise/plugins/chanify.py:47\nmsgid \"Chanify\"\nmsgstr \"\"\n\n#: apprise/plugins/clickatell.py:45\nmsgid \"Clickatell\"\nmsgstr \"\"\n\n#: apprise/plugins/clickatell.py:72 apprise/plugins/rocketchat.py:140\n#, fuzzy\nmsgid \"API Token\"\nmsgstr \"API Key\"\n\n#: apprise/plugins/custom_form.py:148 apprise/plugins/custom_json.py:130\n#: apprise/plugins/custom_xml.py:130\nmsgid \"Fetch Method\"\nmsgstr \"\"\n\n#: apprise/plugins/custom_form.py:154\nmsgid \"Attach File As\"\nmsgstr \"\"\n\n#: apprise/plugins/custom_form.py:169 apprise/plugins/custom_json.py:145\n#: apprise/plugins/custom_xml.py:145 apprise/plugins/pagertree.py:146\nmsgid \"Payload Extras\"\nmsgstr \"\"\n\n#: apprise/plugins/custom_form.py:173 apprise/plugins/custom_json.py:149\n#: apprise/plugins/custom_xml.py:149\nmsgid \"GET Params\"\nmsgstr \"\"\n\n#: apprise/plugins/d7networks.py:97\n#, fuzzy\nmsgid \"API Access Token\"\nmsgstr \"Access Token\"\n\n#: apprise/plugins/d7networks.py:141 apprise/plugins/seven.py:107\nmsgid \"Originating Address\"\nmsgstr \"\"\n\n#: apprise/plugins/dapnet.py:155 apprise/plugins/gotify.py:153\n#: apprise/plugins/growl.py:163 apprise/plugins/join.py:188\n#: apprise/plugins/lametric.py:494 apprise/plugins/ntfy.py:282\n#: apprise/plugins/opsgenie.py:293 apprise/plugins/prowl.py:141\n#: apprise/plugins/pushover.py:217 apprise/plugins/pushsafer.py:387\n#: apprise/plugins/smseagle.py:210\nmsgid \"Priority\"\nmsgstr \"Priority\"\n\n#: apprise/plugins/dapnet.py:161\nmsgid \"Transmitter Groups\"\nmsgstr \"\"\n\n#: apprise/plugins/dbus.py:153\nmsgid \"libdbus-1.so.x must be installed.\"\nmsgstr \"\"\n\n#: apprise/plugins/dbus.py:157 apprise/plugins/glib.py:126\nmsgid \"DBus Notification\"\nmsgstr \"\"\n\n#: apprise/plugins/dbus.py:201 apprise/plugins/glib.py:163\n#: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:128\nmsgid \"Urgency\"\nmsgstr \"Urgency\"\n\n#: apprise/plugins/dbus.py:213 apprise/plugins/glib.py:175\nmsgid \"X-Axis\"\nmsgstr \"X-Axis\"\n\n#: apprise/plugins/dbus.py:219 apprise/plugins/glib.py:181\nmsgid \"Y-Axis\"\nmsgstr \"Y-Axis\"\n\n#: apprise/plugins/dingtalk.py:100 apprise/plugins/signl4.py:76\n#, fuzzy\nmsgid \"Secret\"\nmsgstr \"Secret Key\"\n\n#: apprise/plugins/discord.py:125 apprise/plugins/flock.py:106\n#: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106\n#: apprise/plugins/slack.py:186 apprise/plugins/viber.py:95\n#: apprise/plugins/zulip.py:124\nmsgid \"Bot Name\"\nmsgstr \"Bot Name\"\n\n#: apprise/plugins/discord.py:130 apprise/plugins/fluxer.py:174\n#: apprise/plugins/ifttt.py:103\nmsgid \"Webhook ID\"\nmsgstr \"Webhook ID\"\n\n#: apprise/plugins/discord.py:136 apprise/plugins/fluxer.py:181\n#: apprise/plugins/google_chat.py:118\nmsgid \"Webhook Token\"\nmsgstr \"Webhook Token\"\n\n#: apprise/plugins/discord.py:149 apprise/plugins/fluxer.py:201\nmsgid \"Text To Speech\"\nmsgstr \"Text To Speech\"\n\n#: apprise/plugins/discord.py:154 apprise/plugins/fluxer.py:206\nmsgid \"Avatar Image\"\nmsgstr \"Avatar Image\"\n\n#: apprise/plugins/discord.py:159 apprise/plugins/fluxer.py:211\n#: apprise/plugins/ntfy.py:262\n#, fuzzy\nmsgid \"Avatar URL\"\nmsgstr \"Avatar Image\"\n\n#: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:215\n#: apprise/plugins/pushover.py:229\nmsgid \"URL\"\nmsgstr \"\"\n\n#: apprise/plugins/discord.py:172 apprise/plugins/fluxer.py:222\nmsgid \"Thread ID\"\nmsgstr \"\"\n\n#: apprise/plugins/discord.py:176 apprise/plugins/fluxer.py:230\nmsgid \"Display Footer\"\nmsgstr \"Display Footer\"\n\n#: apprise/plugins/discord.py:181 apprise/plugins/fluxer.py:235\nmsgid \"Footer Logo\"\nmsgstr \"Footer Logo\"\n\n#: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:240\n#, fuzzy\nmsgid \"Use Fields\"\nmsgstr \"To User ID\"\n\n#: apprise/plugins/discord.py:191 apprise/plugins/fluxer.py:245\nmsgid \"Discord Flags\"\nmsgstr \"\"\n\n#: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:256\nmsgid \"Ping Users/Roles\"\nmsgstr \"\"\n\n#: apprise/plugins/dot.py:127\n#, fuzzy\nmsgid \"Device Serial Number\"\nmsgstr \"Device ID\"\n\n#: apprise/plugins/dot.py:133\n#, fuzzy\nmsgid \"API Mode\"\nmsgstr \"API Key\"\n\n#: apprise/plugins/dot.py:147\nmsgid \"Refresh Now\"\nmsgstr \"\"\n\n#: apprise/plugins/dot.py:153\nmsgid \"Text Signature\"\nmsgstr \"\"\n\n#: apprise/plugins/dot.py:157\nmsgid \"Icon Base64 (Text API)\"\nmsgstr \"\"\n\n#: apprise/plugins/dot.py:161\nmsgid \"Image Base64 (Image API)\"\nmsgstr \"\"\n\n#: apprise/plugins/dot.py:166\nmsgid \"Link\"\nmsgstr \"\"\n\n#: apprise/plugins/dot.py:170\n#, fuzzy\nmsgid \"Border\"\nmsgstr \"Modal\"\n\n#: apprise/plugins/dot.py:177\nmsgid \"Dither Type\"\nmsgstr \"\"\n\n#: apprise/plugins/dot.py:183\nmsgid \"Dither Kernel\"\nmsgstr \"\"\n\n#: apprise/plugins/emby.py:112\nmsgid \"Modal\"\nmsgstr \"Modal\"\n\n#: apprise/plugins/enigma2.py:130 apprise/plugins/gotify.py:134\n#: apprise/plugins/mattermost.py:163 apprise/plugins/notica.py:145\nmsgid \"Path\"\nmsgstr \"\"\n\n#: apprise/plugins/enigma2.py:140\nmsgid \"Server Timeout\"\nmsgstr \"Server Timeout\"\n\n#: apprise/plugins/feishu.py:49\nmsgid \"Feishu\"\nmsgstr \"\"\n\n#: apprise/plugins/flock.py:99 apprise/plugins/twitter.py:150\nmsgid \"Access Key\"\nmsgstr \"Access Key\"\n\n#: apprise/plugins/flock.py:111\nmsgid \"To User ID\"\nmsgstr \"To User ID\"\n\n#: apprise/plugins/flock.py:118\nmsgid \"To Channel ID\"\nmsgstr \"To Channel ID\"\n\n#: apprise/plugins/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188\n#: apprise/plugins/fluxer.py:195 apprise/plugins/lametric.py:510\n#: apprise/plugins/mattermost.py:223 apprise/plugins/notificationapi.py:209\n#: apprise/plugins/ntfy.py:296 apprise/plugins/vapid/__init__.py:169\n#, fuzzy\nmsgid \"Mode\"\nmsgstr \"Modal\"\n\n#: apprise/plugins/fluxer.py:226\n#, fuzzy\nmsgid \"Thread Name\"\nmsgstr \"Bot Name\"\n\n#: apprise/plugins/fortysixelks.py:58\nmsgid \"46elks\"\nmsgstr \"\"\n\n#: apprise/plugins/fortysixelks.py:89\n#, fuzzy\nmsgid \"API Username\"\nmsgstr \"User Name\"\n\n#: apprise/plugins/fortysixelks.py:94\n#, fuzzy\nmsgid \"API Password\"\nmsgstr \"Password\"\n\n#: apprise/plugins/freemobile.py:48\nmsgid \"Free-Mobile\"\nmsgstr \"\"\n\n#: apprise/plugins/glib.py:122\nmsgid \"libdbus-1.so.x or libdbus-2.so.x must be installed.\"\nmsgstr \"\"\n\n#: apprise/plugins/gnome.py:100\nmsgid \"A local Gnome environment is required.\"\nmsgstr \"\"\n\n#: apprise/plugins/gnome.py:104\nmsgid \"Gnome Notification\"\nmsgstr \"\"\n\n#: apprise/plugins/google_chat.py:106\nmsgid \"Workspace\"\nmsgstr \"\"\n\n#: apprise/plugins/google_chat.py:112\n#, fuzzy\nmsgid \"Webhook Key\"\nmsgstr \"Webhook Token\"\n\n#: apprise/plugins/google_chat.py:124\n#, fuzzy\nmsgid \"Thread Key\"\nmsgstr \"Secret Key\"\n\n#: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:195\n#: apprise/plugins/msteams.py:206 apprise/plugins/nextcloud.py:163\nmsgid \"Version\"\nmsgstr \"Version\"\n\n#: apprise/plugins/growl.py:181\nmsgid \"Sticky\"\nmsgstr \"\"\n\n#: apprise/plugins/home_assistant.py:99\n#, fuzzy\nmsgid \"Long-Lived Access Token\"\nmsgstr \"Access Token\"\n\n#: apprise/plugins/home_assistant.py:113\nmsgid \"Notification ID\"\nmsgstr \"\"\n\n#: apprise/plugins/ifttt.py:109\nmsgid \"Events\"\nmsgstr \"Events\"\n\n#: apprise/plugins/ifttt.py:129\nmsgid \"Add Tokens\"\nmsgstr \"Add Tokens\"\n\n#: apprise/plugins/ifttt.py:133\nmsgid \"Remove Tokens\"\nmsgstr \"Remove Tokens\"\n\n#: apprise/plugins/join.py:147\nmsgid \"Device ID\"\nmsgstr \"Device ID\"\n\n#: apprise/plugins/join.py:153\n#, fuzzy\nmsgid \"Device Name\"\nmsgstr \"Device ID\"\n\n#: apprise/plugins/kavenegar.py:122 apprise/plugins/messagebird.py:85\n#: apprise/plugins/plivo.py:100\n#, fuzzy\nmsgid \"Source Phone No\"\nmsgstr \"Target Phone No\"\n\n#: apprise/plugins/kumulos.py:99\n#, fuzzy\nmsgid \"Server Key\"\nmsgstr \"Secret Key\"\n\n#: apprise/plugins/lametric.py:436\n#, fuzzy\nmsgid \"Device API Key\"\nmsgstr \"Device ID\"\n\n#: apprise/plugins/lametric.py:442 apprise/plugins/one_signal.py:102\n#: apprise/plugins/parseplatform.py:101\nmsgid \"App ID\"\nmsgstr \"\"\n\n#: apprise/plugins/lametric.py:448\n#, fuzzy\nmsgid \"App Version\"\nmsgstr \"Version\"\n\n#: apprise/plugins/lametric.py:455\n#, fuzzy\nmsgid \"App Access Token\"\nmsgstr \"Access Token\"\n\n#: apprise/plugins/lametric.py:500\nmsgid \"Custom Icon\"\nmsgstr \"\"\n\n#: apprise/plugins/lametric.py:504\nmsgid \"Icon Type\"\nmsgstr \"\"\n\n#: apprise/plugins/lametric.py:521\nmsgid \"Cycles\"\nmsgstr \"\"\n\n#: apprise/plugins/lark.py:47\nmsgid \"Lark (Feishu)\"\nmsgstr \"\"\n\n#: apprise/plugins/lark.py:67 apprise/plugins/revolt.py:98\n#: apprise/plugins/telegram.py:344\nmsgid \"Bot Token\"\nmsgstr \"Bot Token\"\n\n#: apprise/plugins/line.py:82 apprise/plugins/mastodon.py:173\n#: apprise/plugins/matrix.py:239 apprise/plugins/misskey.py:122\n#: apprise/plugins/pushbullet.py:83 apprise/plugins/pushover.py:194\n#: apprise/plugins/smseagle.py:146 apprise/plugins/spugpush.py:68\n#: apprise/plugins/streamlabs.py:105 apprise/plugins/whatsapp.py:99\nmsgid \"Access Token\"\nmsgstr \"Access Token\"\n\n#: apprise/plugins/irc/base.py:137 apprise/plugins/line.py:88\n#: apprise/plugins/mastodon.py:184 apprise/plugins/matrix.py:244\n#: apprise/plugins/nextcloud.py:136 apprise/plugins/one_signal.py:129\n#: apprise/plugins/opsgenie.py:258 apprise/plugins/pushed.py:95\n#: apprise/plugins/rocketchat.py:155 apprise/plugins/slack.py:236\n#: apprise/plugins/twitter.py:162 apprise/plugins/zulip.py:143\nmsgid \"Target User\"\nmsgstr \"Target User\"\n\n#: apprise/plugins/macosx.py:65\nmsgid \"\"\n\"Only works with Mac OS X 10.8 and higher. Additionally  requires that /usr/\"\n\"local/bin/terminal-notifier is locally accessible.\"\nmsgstr \"\"\n\n#: apprise/plugins/macosx.py:72\nmsgid \"MacOSX Notification\"\nmsgstr \"\"\n\n#: apprise/plugins/macosx.py:128\nmsgid \"Open/Click URL\"\nmsgstr \"\"\n\n#: apprise/plugins/email/base.py:124 apprise/plugins/mailgun.py:141\n#: apprise/plugins/sendpulse.py:112 apprise/plugins/smtp2go.py:113\n#: apprise/plugins/sparkpost.py:164\nmsgid \"Domain\"\nmsgstr \"Domain\"\n\n#: apprise/plugins/email/base.py:160 apprise/plugins/mailgun.py:168\n#: apprise/plugins/resend.py:154 apprise/plugins/ses.py:201\n#: apprise/plugins/smtp2go.py:135 apprise/plugins/sparkpost.py:186\nmsgid \"From Name\"\nmsgstr \"From Name\"\n\n#: apprise/plugins/mailgun.py:176 apprise/plugins/notificationapi.py:203\n#: apprise/plugins/opsgenie.py:281 apprise/plugins/pagerduty.py:176\n#: apprise/plugins/sparkpost.py:191\nmsgid \"Region Name\"\nmsgstr \"Region Name\"\n\n#: apprise/plugins/email/base.py:209 apprise/plugins/mailgun.py:204\n#: apprise/plugins/smtp2go.py:161 apprise/plugins/sparkpost.py:219\n#, fuzzy\nmsgid \"Email Header\"\nmsgstr \"HTTP Header\"\n\n#: apprise/plugins/mailgun.py:208 apprise/plugins/msteams.py:222\n#: apprise/plugins/notificationapi.py:246 apprise/plugins/sparkpost.py:223\n#: apprise/plugins/workflows.py:201\n#, fuzzy\nmsgid \"Template Tokens\"\nmsgstr \"Remove Tokens\"\n\n#: apprise/plugins/mastodon.py:204 apprise/plugins/misskey.py:143\nmsgid \"Visibility\"\nmsgstr \"\"\n\n#: apprise/plugins/mastodon.py:210 apprise/plugins/twitter.py:185\nmsgid \"Cache Results\"\nmsgstr \"\"\n\n#: apprise/plugins/mastodon.py:220\nmsgid \"Sensitive Attachments\"\nmsgstr \"\"\n\n#: apprise/plugins/mastodon.py:225\nmsgid \"Spoiler Text\"\nmsgstr \"\"\n\n#: apprise/plugins/mastodon.py:229\nmsgid \"Idempotency-Key\"\nmsgstr \"\"\n\n#: apprise/plugins/mastodon.py:233\nmsgid \"Language Code\"\nmsgstr \"\"\n\n#: apprise/plugins/matrix.py:250 apprise/plugins/rocketchat.py:161\nmsgid \"Target Room ID\"\nmsgstr \"Target Room ID\"\n\n#: apprise/plugins/matrix.py:256\nmsgid \"Target Room Alias\"\nmsgstr \"Target Room Alias\"\n\n#: apprise/plugins/matrix.py:279\n#, fuzzy\nmsgid \"Server Discovery\"\nmsgstr \"Server Timeout\"\n\n#: apprise/plugins/matrix.py:284\nmsgid \"Force Home Server on Room IDs\"\nmsgstr \"\"\n\n#: apprise/plugins/matrix.py:289 apprise/plugins/rocketchat.py:177\n#: apprise/plugins/ryver.py:118\nmsgid \"Webhook Mode\"\nmsgstr \"Webhook Mode\"\n\n#: apprise/plugins/matrix.py:295\nmsgid \"Matrix API Verion\"\nmsgstr \"\"\n\n#: apprise/plugins/matrix.py:301 apprise/plugins/notificationapi.py:154\nmsgid \"Message Type\"\nmsgstr \"\"\n\n#: apprise/plugins/irc/base.py:128 apprise/plugins/mattermost.py:147\n#: apprise/plugins/xmpp/base.py:107\n#, fuzzy\nmsgid \"User\"\nmsgstr \"Username\"\n\n#: apprise/plugins/irc/base.py:143 apprise/plugins/mattermost.py:173\n#: apprise/plugins/notifiarr.py:97 apprise/plugins/pushbullet.py:94\n#: apprise/plugins/pushed.py:101 apprise/plugins/rocketchat.py:149\n#: apprise/plugins/slack.py:242 apprise/plugins/twist.py:112\nmsgid \"Target Channel\"\nmsgstr \"Target Channel\"\n\n#: apprise/plugins/mattermost.py:179 apprise/plugins/twist.py:118\n#, fuzzy\nmsgid \"Target Channel ID\"\nmsgstr \"Target Channel\"\n\n#: apprise/plugins/mqtt.py:169\n#, fuzzy\nmsgid \"Target Queue\"\nmsgstr \"Target User\"\n\n#: apprise/plugins/mqtt.py:188\nmsgid \"QOS\"\nmsgstr \"\"\n\n#: apprise/plugins/mqtt.py:201 apprise/plugins/notificationapi.py:161\n#: apprise/plugins/office365.py:130 apprise/plugins/sendpulse.py:117\n#, fuzzy\nmsgid \"Client ID\"\nmsgstr \"Account SID\"\n\n#: apprise/plugins/mqtt.py:205\nmsgid \"Use Session\"\nmsgstr \"\"\n\n#: apprise/plugins/mqtt.py:210\nmsgid \"Retain Messages\"\nmsgstr \"\"\n\n#: apprise/plugins/msg91.py:102 apprise/plugins/sendpulse.py:161\nmsgid \"Template ID\"\nmsgstr \"\"\n\n#: apprise/plugins/msg91.py:109\n#, fuzzy\nmsgid \"Authentication Key\"\nmsgstr \"Application Key\"\n\n#: apprise/plugins/msg91.py:138\nmsgid \"Short URL\"\nmsgstr \"\"\n\n#: apprise/plugins/msg91.py:148 apprise/plugins/whatsapp.py:168\nmsgid \"Template Mapping\"\nmsgstr \"\"\n\n#: apprise/plugins/msteams.py:151\n#, fuzzy\nmsgid \"Team Name\"\nmsgstr \"Bot Name\"\n\n#: apprise/plugins/msteams.py:159 apprise/plugins/slack.py:203\nmsgid \"Token A\"\nmsgstr \"Token A\"\n\n#: apprise/plugins/msteams.py:168 apprise/plugins/slack.py:211\nmsgid \"Token B\"\nmsgstr \"Token B\"\n\n#: apprise/plugins/msteams.py:177 apprise/plugins/slack.py:219\nmsgid \"Token C\"\nmsgstr \"Token C\"\n\n#: apprise/plugins/msteams.py:186\n#, fuzzy\nmsgid \"Token D\"\nmsgstr \"Token C\"\n\n#: apprise/plugins/msteams.py:212 apprise/plugins/workflows.py:180\nmsgid \"Template Path\"\nmsgstr \"\"\n\n#: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:113\nmsgid \"URL Prefix\"\nmsgstr \"\"\n\n#: apprise/plugins/nextcloudtalk.py:43\nmsgid \"Nextcloud Talk\"\nmsgstr \"\"\n\n#: apprise/plugins/nextcloudtalk.py:96\n#, fuzzy\nmsgid \"Room ID\"\nmsgstr \"Target Room ID\"\n\n#: apprise/plugins/notifiarr.py:121\nmsgid \"Discord Event ID\"\nmsgstr \"\"\n\n#: apprise/plugins/notifiarr.py:131 apprise/plugins/pagerduty.py:145\n#, fuzzy\nmsgid \"Source\"\nmsgstr \"Source JID\"\n\n#: apprise/plugins/notificationapi.py:166 apprise/plugins/office365.py:137\n#: apprise/plugins/sendpulse.py:124\n#, fuzzy\nmsgid \"Client Secret\"\nmsgstr \"Access Secret\"\n\n#: apprise/plugins/notificationapi.py:177\n#, fuzzy\nmsgid \"Target ID\"\nmsgstr \"Target User\"\n\n#: apprise/plugins/notificationapi.py:182\n#, fuzzy\nmsgid \"Target SMS\"\nmsgstr \"Targets\"\n\n#: apprise/plugins/notificationapi.py:198\nmsgid \"Channels\"\nmsgstr \"Channels\"\n\n#: apprise/plugins/email/base.py:185 apprise/plugins/notificationapi.py:226\n#: apprise/plugins/resend.py:146\nmsgid \"Reply To\"\nmsgstr \"\"\n\n#: apprise/plugins/email/base.py:155 apprise/plugins/notificationapi.py:231\n#: apprise/plugins/sendpulse.py:147 apprise/plugins/ses.py:154\nmsgid \"From Email\"\nmsgstr \"From Email\"\n\n#: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:124\n#, fuzzy\nmsgid \"Project ID\"\nmsgstr \"Target JID\"\n\n#: apprise/plugins/notifico.py:133\nmsgid \"Message Hook\"\nmsgstr \"\"\n\n#: apprise/plugins/notifico.py:148\nmsgid \"IRC Colors\"\nmsgstr \"\"\n\n#: apprise/plugins/notifico.py:154\nmsgid \"Prefix\"\nmsgstr \"\"\n\n#: apprise/plugins/ntfy.py:235\nmsgid \"Topic\"\nmsgstr \"\"\n\n#: apprise/plugins/ntfy.py:252\nmsgid \"Attach\"\nmsgstr \"\"\n\n#: apprise/plugins/ntfy.py:266\nmsgid \"Attach Filename\"\nmsgstr \"\"\n\n#: apprise/plugins/ntfy.py:274\nmsgid \"Delay\"\nmsgstr \"\"\n\n#: apprise/plugins/ntfy.py:278 apprise/plugins/twist.py:107\n#, fuzzy\nmsgid \"Email\"\nmsgstr \"To Email\"\n\n#: apprise/plugins/ntfy.py:292\n#, fuzzy\nmsgid \"Actions\"\nmsgstr \"Duration\"\n\n#: apprise/plugins/ntfy.py:305\n#, fuzzy\nmsgid \"Authentication Type\"\nmsgstr \"Authorization Token\"\n\n#: apprise/plugins/office365.py:118\n#, fuzzy\nmsgid \"Tenant Domain\"\nmsgstr \"Domain\"\n\n#: apprise/plugins/office365.py:125\nmsgid \"Account Email or Object ID\"\nmsgstr \"\"\n\n#: apprise/plugins/one_signal.py:108 apprise/plugins/sendgrid.py:157\nmsgid \"Template\"\nmsgstr \"\"\n\n#: apprise/plugins/one_signal.py:119\n#, fuzzy\nmsgid \"Target Player ID\"\nmsgstr \"Target Tag ID\"\n\n#: apprise/plugins/one_signal.py:135\n#, fuzzy\nmsgid \"Include Segment\"\nmsgstr \"Include Image\"\n\n#: apprise/plugins/one_signal.py:166\nmsgid \"Enable Contents\"\nmsgstr \"\"\n\n#: apprise/plugins/one_signal.py:172\nmsgid \"Decode Template Args\"\nmsgstr \"\"\n\n#: apprise/plugins/one_signal.py:181\nmsgid \"Subtitle\"\nmsgstr \"\"\n\n#: apprise/plugins/one_signal.py:185 apprise/plugins/sfr.py:125\n#: apprise/plugins/whatsapp.py:130\nmsgid \"Language\"\nmsgstr \"\"\n\n#: apprise/plugins/one_signal.py:195\nmsgid \"Custom Data\"\nmsgstr \"\"\n\n#: apprise/plugins/one_signal.py:199\nmsgid \"Postback Data\"\nmsgstr \"\"\n\n#: apprise/plugins/opsgenie.py:246\n#, fuzzy\nmsgid \"Target Escalation\"\nmsgstr \"Target Chat ID\"\n\n#: apprise/plugins/opsgenie.py:252\n#, fuzzy\nmsgid \"Target Schedule\"\nmsgstr \"Target Channel\"\n\n#: apprise/plugins/opsgenie.py:264\n#, fuzzy\nmsgid \"Target Team\"\nmsgstr \"Target Email\"\n\n#: apprise/plugins/opsgenie.py:270\n#, fuzzy\nmsgid \"Targets \"\nmsgstr \"Targets\"\n\n#: apprise/plugins/opsgenie.py:299\nmsgid \"Entity\"\nmsgstr \"\"\n\n#: apprise/plugins/opsgenie.py:303\nmsgid \"Alias\"\nmsgstr \"\"\n\n#: apprise/plugins/opsgenie.py:314 apprise/plugins/pagertree.py:118\n#: apprise/plugins/splunk.py:202\n#, fuzzy\nmsgid \"Action\"\nmsgstr \"Duration\"\n\n#: apprise/plugins/opsgenie.py:325\n#, fuzzy\nmsgid \"Details\"\nmsgstr \"Target Emails\"\n\n#: apprise/plugins/opsgenie.py:329 apprise/plugins/splunk.py:213\nmsgid \"Action Mapping\"\nmsgstr \"\"\n\n#: apprise/plugins/pagerduty.py:138 apprise/plugins/spike.py:68\n#, fuzzy\nmsgid \"Integration Key\"\nmsgstr \"Application Key\"\n\n#: apprise/plugins/pagerduty.py:151\n#, fuzzy\nmsgid \"Component\"\nmsgstr \"From Phone No\"\n\n#: apprise/plugins/pagerduty.py:167\nmsgid \"Class\"\nmsgstr \"\"\n\n#: apprise/plugins/pagerduty.py:185\nmsgid \"Severity\"\nmsgstr \"\"\n\n#: apprise/plugins/pagerduty.py:202\n#, fuzzy\nmsgid \"Custom Details\"\nmsgstr \"To Email\"\n\n#: apprise/plugins/pagertree.py:105\nmsgid \"Integration ID\"\nmsgstr \"\"\n\n#: apprise/plugins/pagertree.py:124\nmsgid \"Third Party ID\"\nmsgstr \"\"\n\n#: apprise/plugins/pagertree.py:150\nmsgid \"Meta Extras\"\nmsgstr \"\"\n\n#: apprise/plugins/parseplatform.py:107\n#, fuzzy\nmsgid \"Master Key\"\nmsgstr \"User Key\"\n\n#: apprise/plugins/parseplatform.py:120\n#, fuzzy\nmsgid \"Device\"\nmsgstr \"Device ID\"\n\n#: apprise/plugins/plivo.py:88\n#, fuzzy\nmsgid \"Auth ID\"\nmsgstr \"Account SID\"\n\n#: apprise/plugins/plivo.py:94 apprise/plugins/sinch.py:113\n#: apprise/plugins/twilio.py:147\nmsgid \"Auth Token\"\nmsgstr \"Auth Token\"\n\n#: apprise/plugins/prowl.py:128\nmsgid \"Provider Key\"\nmsgstr \"Provider Key\"\n\n#: apprise/plugins/pushdeer.py:85\n#, fuzzy\nmsgid \"Pushkey\"\nmsgstr \"User Key\"\n\n#: apprise/plugins/pushed.py:83\nmsgid \"Application Key\"\nmsgstr \"Application Key\"\n\n#: apprise/plugins/pushed.py:89 apprise/plugins/reddit.py:158\nmsgid \"Application Secret\"\nmsgstr \"Application Secret\"\n\n#: apprise/plugins/pushjet.py:82\nmsgid \"Secret Key\"\nmsgstr \"Secret Key\"\n\n#: apprise/plugins/pushme.py:81 apprise/plugins/signal_api.py:160\n#: apprise/plugins/smseagle.py:195\nmsgid \"Show Status\"\nmsgstr \"\"\n\n#: apprise/plugins/pushover.py:188\nmsgid \"User Key\"\nmsgstr \"User Key\"\n\n#: apprise/plugins/pushover.py:234\nmsgid \"URL Title\"\nmsgstr \"\"\n\n#: apprise/plugins/pushover.py:239\nmsgid \"Retry\"\nmsgstr \"\"\n\n#: apprise/plugins/pushover.py:245\nmsgid \"Expire\"\nmsgstr \"\"\n\n#: apprise/plugins/pushplus.py:48\nmsgid \"Pushplus\"\nmsgstr \"\"\n\n#: apprise/plugins/pushplus.py:68 apprise/plugins/qq.py:66\n#, fuzzy\nmsgid \"User Token\"\nmsgstr \"User Key\"\n\n#: apprise/plugins/pushsafer.py:360\n#, fuzzy\nmsgid \"Private Key\"\nmsgstr \"Provider Key\"\n\n#: apprise/plugins/pushsafer.py:397\n#, fuzzy\nmsgid \"Vibration\"\nmsgstr \"Duration\"\n\n#: apprise/plugins/pushy.py:79\n#, fuzzy\nmsgid \"Secret API Key\"\nmsgstr \"Secret Key\"\n\n#: apprise/plugins/fcm/__init__.py:162 apprise/plugins/pushy.py:91\n#: apprise/plugins/sns.py:131 apprise/plugins/wxpusher.py:128\nmsgid \"Target Topic\"\nmsgstr \"Target Topic\"\n\n#: apprise/plugins/qq.py:46\nmsgid \"QQ Push\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:151\n#, fuzzy\nmsgid \"Application ID\"\nmsgstr \"Application Key\"\n\n#: apprise/plugins/reddit.py:165\n#, fuzzy\nmsgid \"Target Subreddit\"\nmsgstr \"Target User\"\n\n#: apprise/plugins/reddit.py:185\nmsgid \"Kind\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:191\nmsgid \"Flair ID\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:196\nmsgid \"Flair Text\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:201\nmsgid \"NSFW\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:207\nmsgid \"Is Ad?\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:213\nmsgid \"Send Replies\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:219\nmsgid \"Is Spoiler\"\nmsgstr \"\"\n\n#: apprise/plugins/reddit.py:225\nmsgid \"Resubmit Flag\"\nmsgstr \"\"\n\n#: apprise/plugins/revolt.py:104\n#, fuzzy\nmsgid \"Channel ID\"\nmsgstr \"To Channel ID\"\n\n#: apprise/plugins/revolt.py:130\nmsgid \"Embed URL\"\nmsgstr \"\"\n\n#: apprise/plugins/rocketchat.py:145\nmsgid \"Webhook\"\nmsgstr \"Webhook\"\n\n#: apprise/plugins/rocketchat.py:182\nmsgid \"Use Avatar\"\nmsgstr \"Use Avatar\"\n\n#: apprise/plugins/rsyslog.py:173 apprise/plugins/syslog.py:144\nmsgid \"Facility\"\nmsgstr \"\"\n\n#: apprise/plugins/rsyslog.py:203 apprise/plugins/syslog.py:161\nmsgid \"Log PID\"\nmsgstr \"\"\n\n#: apprise/plugins/ryver.py:93 apprise/plugins/zulip.py:130\nmsgid \"Organization\"\nmsgstr \"Organization\"\n\n#: apprise/plugins/sendgrid.py:166 apprise/plugins/sendpulse.py:175\nmsgid \"Template Data\"\nmsgstr \"\"\n\n#: apprise/plugins/ses.py:160 apprise/plugins/sns.py:106\nmsgid \"Access Key ID\"\nmsgstr \"Access Key ID\"\n\n#: apprise/plugins/ses.py:166 apprise/plugins/sns.py:112\nmsgid \"Secret Access Key\"\nmsgstr \"Secret Access Key\"\n\n#: apprise/plugins/ses.py:172 apprise/plugins/sinch.py:160\n#: apprise/plugins/sns.py:118\nmsgid \"Region\"\nmsgstr \"Region\"\n\n#: apprise/plugins/ses.py:179 apprise/plugins/smtp2go.py:124\n#: apprise/plugins/sparkpost.py:175\nmsgid \"Target Emails\"\nmsgstr \"Target Emails\"\n\n#: apprise/plugins/seven.py:115 apprise/plugins/smseagle.py:205\nmsgid \"Flash\"\nmsgstr \"\"\n\n#: apprise/plugins/seven.py:119\nmsgid \"Label\"\nmsgstr \"\"\n\n#: apprise/plugins/sfr.py:58\nmsgid \"Société Française du Radiotéléphone\"\nmsgstr \"\"\n\n#: apprise/plugins/sfr.py:90\n#, fuzzy\nmsgid \"Service ID\"\nmsgstr \"Device ID\"\n\n#: apprise/plugins/sfr.py:95\n#, fuzzy\nmsgid \"Service Password\"\nmsgstr \"Password\"\n\n#: apprise/plugins/sfr.py:101\n#, fuzzy\nmsgid \"Space ID\"\nmsgstr \"Source JID\"\n\n#: apprise/plugins/sfr.py:107\nmsgid \"Recipient Phone Number\"\nmsgstr \"\"\n\n#: apprise/plugins/sfr.py:131\n#, fuzzy\nmsgid \"Sender Name\"\nmsgstr \"User Name\"\n\n#: apprise/plugins/sfr.py:138\nmsgid \"Media Type\"\nmsgstr \"\"\n\n#: apprise/plugins/sfr.py:145\n#, fuzzy\nmsgid \"Timeout\"\nmsgstr \"Server Timeout\"\n\n#: apprise/plugins/sfr.py:151\n#, fuzzy\nmsgid \"TTS Voice\"\nmsgstr \"Target Device\"\n\n#: apprise/plugins/signal_api.py:131 apprise/plugins/smseagle.py:158\n#, fuzzy\nmsgid \"Target Group ID\"\nmsgstr \"Target Room ID\"\n\n#: apprise/plugins/signl4.py:86\n#, fuzzy\nmsgid \"Service\"\nmsgstr \"Device ID\"\n\n#: apprise/plugins/signl4.py:90\n#, fuzzy\nmsgid \"Location\"\nmsgstr \"Duration\"\n\n#: apprise/plugins/signl4.py:94\nmsgid \"Alerting Scenario\"\nmsgstr \"\"\n\n#: apprise/plugins/signl4.py:98\nmsgid \"Filtering\"\nmsgstr \"\"\n\n#: apprise/plugins/signl4.py:103\n#, fuzzy\nmsgid \"External ID\"\nmsgstr \"To User ID\"\n\n#: apprise/plugins/signl4.py:107\n#, fuzzy\nmsgid \"Status\"\nmsgstr \"Targets\"\n\n#: apprise/plugins/simplepush.py:113\nmsgid \"Salt\"\nmsgstr \"\"\n\n#: apprise/plugins/simplepush.py:126\n#, fuzzy\nmsgid \"Event\"\nmsgstr \"Events\"\n\n#: apprise/plugins/sinch.py:106 apprise/plugins/twilio.py:140\nmsgid \"Account SID\"\nmsgstr \"Account SID\"\n\n#: apprise/plugins/sinch.py:134 apprise/plugins/twilio.py:168\nmsgid \"Target Short Code\"\nmsgstr \"Target Short Code\"\n\n#: apprise/plugins/slack.py:194\n#, fuzzy\nmsgid \"OAuth Access Token\"\nmsgstr \"Access Token\"\n\n#: apprise/plugins/slack.py:225\nmsgid \"Target Encoded ID\"\nmsgstr \"Target Encoded ID\"\n\n#: apprise/plugins/slack.py:265\n#, fuzzy\nmsgid \"Include Footer\"\nmsgstr \"Include Image\"\n\n#: apprise/plugins/slack.py:273\nmsgid \"Use Blocks\"\nmsgstr \"\"\n\n#: apprise/plugins/slack.py:282\n#, fuzzy\nmsgid \"Include Timestamp\"\nmsgstr \"Include Image\"\n\n#: apprise/plugins/slack.py:288 apprise/plugins/twitter.py:179\n#, fuzzy\nmsgid \"Message Mode\"\nmsgstr \"Secure Mode\"\n\n#: apprise/plugins/smpp.py:61\nmsgid \"SMPP\"\nmsgstr \"\"\n\n#: apprise/plugins/smpp.py:103\n#, fuzzy\nmsgid \"Host\"\nmsgstr \"Hostname\"\n\n#: apprise/plugins/smseagle.py:165\n#, fuzzy\nmsgid \"Target Contact\"\nmsgstr \"Target Chat ID\"\n\n#: apprise/plugins/smseagle.py:200\nmsgid \"Test Only\"\nmsgstr \"\"\n\n#: apprise/plugins/smsmanager.py:146\nmsgid \"Gateway\"\nmsgstr \"\"\n\n#: apprise/plugins/spike.py:48\nmsgid \"Spike.sh\"\nmsgstr \"\"\n\n#: apprise/plugins/splunk.py:117\nmsgid \"Splunk On-Call\"\nmsgstr \"\"\n\n#: apprise/plugins/splunk.py:172\n#, fuzzy\nmsgid \"Target Routing Key\"\nmsgstr \"Target Tag ID\"\n\n#: apprise/plugins/splunk.py:179\nmsgid \"Entity ID\"\nmsgstr \"\"\n\n#: apprise/plugins/spugpush.py:48\nmsgid \"SpugPush\"\nmsgstr \"\"\n\n#: apprise/plugins/streamlabs.py:125\nmsgid \"Alert Type\"\nmsgstr \"\"\n\n#: apprise/plugins/streamlabs.py:131\nmsgid \"Image Link\"\nmsgstr \"\"\n\n#: apprise/plugins/streamlabs.py:136\n#, fuzzy\nmsgid \"Sound Link\"\nmsgstr \"Sound\"\n\n#: apprise/plugins/streamlabs.py:141 apprise/plugins/windows.py:100\n#: apprise/plugins/xbmc.py:123\nmsgid \"Duration\"\nmsgstr \"Duration\"\n\n#: apprise/plugins/streamlabs.py:147\nmsgid \"Special Text Color\"\nmsgstr \"\"\n\n#: apprise/plugins/streamlabs.py:153\nmsgid \"Amount\"\nmsgstr \"\"\n\n#: apprise/plugins/streamlabs.py:159\n#, fuzzy\nmsgid \"Currency\"\nmsgstr \"Urgency\"\n\n#: apprise/plugins/streamlabs.py:165\n#, fuzzy\nmsgid \"Name\"\nmsgstr \"Username\"\n\n#: apprise/plugins/streamlabs.py:171\nmsgid \"Identifier\"\nmsgstr \"\"\n\n#: apprise/plugins/synology.py:116\nmsgid \"Upload\"\nmsgstr \"\"\n\n#: apprise/plugins/syslog.py:167\nmsgid \"Log to STDERR\"\nmsgstr \"\"\n\n#: apprise/plugins/telegram.py:353\nmsgid \"Target Chat ID\"\nmsgstr \"Target Chat ID\"\n\n#: apprise/plugins/telegram.py:376\nmsgid \"Detect Bot Owner\"\nmsgstr \"Detect Bot Owner\"\n\n#: apprise/plugins/telegram.py:382\nmsgid \"Silent Notification\"\nmsgstr \"\"\n\n#: apprise/plugins/telegram.py:387\nmsgid \"Web Page Preview\"\nmsgstr \"\"\n\n#: apprise/plugins/telegram.py:392\nmsgid \"Topic Thread ID\"\nmsgstr \"\"\n\n#: apprise/plugins/telegram.py:399\n#, fuzzy\nmsgid \"Markdown Version\"\nmsgstr \"Version\"\n\n#: apprise/plugins/telegram.py:408\nmsgid \"Content Placement\"\nmsgstr \"\"\n\n#: apprise/plugins/threema.py:85\nmsgid \"Gateway ID\"\nmsgstr \"\"\n\n#: apprise/plugins/threema.py:110\n#, fuzzy\nmsgid \"Target Threema ID\"\nmsgstr \"Target Tag ID\"\n\n#: apprise/plugins/twilio.py:203\nmsgid \"Notification Method: sms or call\"\nmsgstr \"\"\n\n#: apprise/plugins/twitter.py:138\nmsgid \"Consumer Key\"\nmsgstr \"Consumer Key\"\n\n#: apprise/plugins/twitter.py:144\nmsgid \"Consumer Secret\"\nmsgstr \"Consumer Secret\"\n\n#: apprise/plugins/twitter.py:156\nmsgid \"Access Secret\"\nmsgstr \"Access Secret\"\n\n#: apprise/plugins/viber.py:49\nmsgid \"Viber\"\nmsgstr \"\"\n\n#: apprise/plugins/viber.py:81\n#, fuzzy\nmsgid \"Authentication Token\"\nmsgstr \"Application Key\"\n\n#: apprise/plugins/viber.py:87\nmsgid \"Receiver IDs\"\nmsgstr \"\"\n\n#: apprise/plugins/viber.py:101\n#, fuzzy\nmsgid \"Bot Avatar URL\"\nmsgstr \"Avatar Image\"\n\n#: apprise/plugins/voipms.py:83\n#, fuzzy\nmsgid \"User Email\"\nmsgstr \"From Email\"\n\n#: apprise/plugins/vapid/__init__.py:179 apprise/plugins/vonage.py:136\nmsgid \"ttl\"\nmsgstr \"\"\n\n#: apprise/plugins/wecombot.py:99\n#, fuzzy\nmsgid \"Bot Webhook Key\"\nmsgstr \"Webhook Token\"\n\n#: apprise/plugins/whatsapp.py:106\nmsgid \"Template Name\"\nmsgstr \"\"\n\n#: apprise/plugins/whatsapp.py:112\n#, fuzzy\nmsgid \"From Phone ID\"\nmsgstr \"From Phone No\"\n\n#: apprise/plugins/windows.py:62\nmsgid \"A local Microsoft Windows environment is required.\"\nmsgstr \"\"\n\n#: apprise/plugins/workflows.py:137\n#, fuzzy\nmsgid \"Workflow ID\"\nmsgstr \"Overflow Mode\"\n\n#: apprise/plugins/workflows.py:145\nmsgid \"Signature\"\nmsgstr \"\"\n\n#: apprise/plugins/workflows.py:168\nmsgid \"Use Power Automate URL\"\nmsgstr \"\"\n\n#: apprise/plugins/workflows.py:175\nmsgid \"Wrap Text\"\nmsgstr \"\"\n\n#: apprise/plugins/workflows.py:190\n#, fuzzy\nmsgid \"API Version\"\nmsgstr \"Version\"\n\n#: apprise/plugins/wxpusher.py:121\n#, fuzzy\nmsgid \"App Token\"\nmsgstr \"Auth Token\"\n\n#: apprise/plugins/wxpusher.py:133\n#, fuzzy\nmsgid \"Target User ID\"\nmsgstr \"Target User\"\n\n#: apprise/plugins/zulip.py:148\n#, fuzzy\nmsgid \"Target Stream\"\nmsgstr \"Target User\"\n\n#: apprise/plugins/email/base.py:150\nmsgid \"To Email\"\nmsgstr \"To Email\"\n\n#: apprise/plugins/email/base.py:173\nmsgid \"SMTP Server\"\nmsgstr \"SMTP Server\"\n\n#: apprise/plugins/email/base.py:178 apprise/plugins/xmpp/base.py:129\nmsgid \"Secure Mode\"\nmsgstr \"Secure Mode\"\n\n#: apprise/plugins/email/base.py:190\nmsgid \"PGP Encryption\"\nmsgstr \"\"\n\n#: apprise/plugins/email/base.py:196\nmsgid \"PGP Public Key Path\"\nmsgstr \"\"\n\n#: apprise/plugins/fcm/__init__.py:148\nmsgid \"OAuth2 KeyFile\"\nmsgstr \"\"\n\n#: apprise/plugins/fcm/__init__.py:193\nmsgid \"Custom Image URL\"\nmsgstr \"\"\n\n#: apprise/plugins/fcm/__init__.py:205\nmsgid \"Notification Color\"\nmsgstr \"\"\n\n#: apprise/plugins/fcm/__init__.py:215\nmsgid \"Data Entries\"\nmsgstr \"\"\n\n#: apprise/plugins/irc/base.py:159\n#, fuzzy\nmsgid \"Real Name\"\nmsgstr \"Bot Name\"\n\n#: apprise/plugins/irc/base.py:160\n#, fuzzy\nmsgid \"Nickname\"\nmsgstr \"Username\"\n\n#: apprise/plugins/irc/base.py:162\n#, fuzzy\nmsgid \"Join Channels\"\nmsgstr \"Channels\"\n\n#: apprise/plugins/irc/base.py:167\n#, fuzzy\nmsgid \"Auth Mode\"\nmsgstr \"Webhook Mode\"\n\n#: apprise/plugins/vapid/__init__.py:193\nmsgid \"PEM Private KeyFile\"\nmsgstr \"\"\n\n#: apprise/plugins/vapid/__init__.py:199\nmsgid \"Subscripion File\"\nmsgstr \"\"\n\n#: apprise/plugins/xmpp/base.py:136\n#, fuzzy\nmsgid \"Get Roster\"\nmsgstr \"Target User\"\n\n#: apprise/plugins/xmpp/base.py:141\nmsgid \"Use Subject\"\nmsgstr \"\"\n\n#: apprise/plugins/xmpp/base.py:146\n#, fuzzy\nmsgid \"Keep Connection Alive\"\nmsgstr \"Server Timeout\"\n"
  },
  {
    "path": "apprise/locale.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nimport ctypes\nimport locale\nimport os\nfrom os.path import abspath, dirname, join\nimport re\nfrom typing import Union\n\nfrom .logger import logger\n\n# This gets toggled to True if we succeed\nGETTEXT_LOADED = False\n\ntry:\n    # Initialize gettext\n    import gettext\n\n    # Toggle our flag\n    GETTEXT_LOADED = True\n\nexcept ImportError:\n    # gettext isn't available; no problem; Use the library features without\n    # multi-language support.\n    pass\n\n\nclass AppriseLocale:\n    \"\"\"A wrapper class to gettext so that we can manipulate multiple lanaguages\n    on the fly if required.\"\"\"\n\n    # Define our translation domain\n    _domain = \"apprise\"\n\n    # The path to our translations\n    _locale_dir = abspath(join(dirname(__file__), \"i18n\"))\n\n    # Locale regular expression\n    _local_re = re.compile(\n        r\"^((?P<ansii>C)|(?P<lang>([a-z]{2}))([_:](?P<country>[a-z]{2}))?)\"\n        r\"(\\.(?P<enc>[a-z0-9-]+))?$\",\n        re.IGNORECASE,\n    )\n\n    # Define our default encoding\n    _default_encoding = \"utf-8\"\n\n    # The function to assign `_` by default\n    _fn = \"gettext\"\n\n    # The language we should fall back to if all else fails\n    _default_language = \"en\"\n\n    def __init__(self, language=None):\n        \"\"\"Initializes our object, if a language is specified, then we\n        initialize ourselves to that, otherwise we use whatever we detect from\n        the local operating system.\n\n        If all else fails, we resort to the defined default_language.\n        \"\"\"\n\n        # Cache previously loaded translations\n        self._gtobjs = {}\n\n        # Get our language\n        self.lang = AppriseLocale.detect_language(language)\n\n        # Our mapping to our _fn\n        self.__fn_map = None\n\n        if GETTEXT_LOADED is False:\n            # We're done\n            return\n\n        # Add language\n        self.add(self.lang)\n\n    def add(self, lang=None, set_default=True):\n        \"\"\"Add a language to our list.\"\"\"\n        lang = lang if lang else self._default_language\n        if lang not in self._gtobjs:\n            # Load our gettext object and install our language\n            try:\n                self._gtobjs[lang] = gettext.translation(\n                    self._domain,\n                    localedir=self._locale_dir,\n                    languages=[lang],\n                    fallback=False,\n                )\n\n                # The non-intrusive method of applying the gettext change to\n                # the global namespace only\n                self.__fn_map = getattr(self._gtobjs[lang], self._fn)\n\n            except FileNotFoundError:\n                # The translation directory does not exist\n                logger.debug(\n                    \"Could not load translation path: %s\",\n                    join(self._locale_dir, lang),\n                )\n\n                # Fallback (handle case where self.lang does not exist)\n                if self.lang not in self._gtobjs:\n                    self._gtobjs[self.lang] = gettext\n                    self.__fn_map = getattr(self._gtobjs[self.lang], self._fn)\n\n                return False\n\n            logger.trace(\"Loaded language %s\", lang)\n\n        if set_default:\n            logger.debug(\"Language set to %s\", lang)\n            self.lang = lang\n\n        return True\n\n    @contextlib.contextmanager\n    def lang_at(self, lang, mapto=_fn):\n        \"\"\"\n        The syntax works as:\n            with at.lang_at('fr'):\n                # apprise works as though the french language has been\n                # defined. afterwards, the language falls back to whatever\n                # it was.\n        \"\"\"\n\n        if GETTEXT_LOADED is False:\n            # Do nothing\n            yield None\n\n            # we're done\n            return\n\n        # Tidy the language\n        lang = AppriseLocale.detect_language(lang, detect_fallback=False)\n        if lang not in self._gtobjs and not self.add(lang, set_default=False):\n            # Do Nothing\n            yield getattr(self._gtobjs[self.lang], mapto)\n        else:\n            # Yield\n            yield getattr(self._gtobjs[lang], mapto)\n\n        return\n\n    @property\n    def gettext(self):\n        \"\"\"Return the current language gettext() function.\n\n        Useful for assigning to `_`\n        \"\"\"\n        return self._gtobjs[self.lang].gettext\n\n    @staticmethod\n    def detect_language(lang=None, detect_fallback=True):\n        \"\"\"Returns the language (if it's retrievable)\"\"\"\n        # We want to only use the 2 character version of this language\n        # hence en_CA becomes en, en_US becomes en.\n        if not isinstance(lang, str):\n            if detect_fallback is False:\n                # no detection enabled; we're done\n                return None\n\n            # Posix lookup\n            lookup = os.environ.get\n            localename = None\n            for variable in (\"LC_ALL\", \"LC_CTYPE\", \"LANG\", \"LANGUAGE\"):\n                localename = lookup(variable, None)\n                if localename:\n                    result = AppriseLocale._local_re.match(localename)\n                    if result and result.group(\"lang\"):\n                        return result.group(\"lang\").lower()\n\n            # Windows handling\n            if hasattr(ctypes, \"windll\"):\n                windll = ctypes.windll.kernel32\n                try:\n                    lang = locale.windows_locale[\n                        windll.GetUserDefaultUILanguage()\n                    ]\n\n                    # Our detected windows language\n                    return lang[0:2].lower()\n\n                except (TypeError, KeyError):\n                    # Fallback to posix detection\n                    pass\n\n            # Built in locale library check\n            try:\n                # Acquire our locale\n                lang = locale.getlocale()[0]\n                # Compatibility for Python >= 3.12\n                if lang == \"C\":\n                    lang = AppriseLocale._default_language\n\n            except (ValueError, TypeError) as e:\n                # This occurs when an invalid locale was parsed from the\n                # environment variable. While we still return None in this\n                # case, we want to better notify the end user of this. Users\n                # receiving this error should check their environment\n                # variables.\n                logger.warning(f\"Language detection failure / {e!s}\")\n                return None\n\n        return None if not lang else lang[0:2].lower()\n\n    def __getstate__(self):\n        \"\"\"Pickle Support dumps()\"\"\"\n        state = self.__dict__.copy()\n\n        # Remove the unpicklable entries.\n        del state[\"_gtobjs\"]\n        del state[\"_AppriseLocale__fn_map\"]\n        return state\n\n    def __setstate__(self, state):\n        \"\"\"Pickle Support loads()\"\"\"\n        self.__dict__.update(state)\n        # Our mapping to our _fn\n        self.__fn_map = None\n        self._gtobjs = {}\n        self.add(state[\"lang\"], set_default=True)\n\n\n#\n# Prepare our default LOCALE Singleton\n#\nLOCALE = AppriseLocale()\n\n\nclass LazyTranslation:\n    \"\"\"Doesn't translate anything until str() or unicode() references are\n    made.\"\"\"\n\n    def __init__(self, text, *args, **kwargs):\n        \"\"\"Store our text.\"\"\"\n        self.text = text\n\n        super().__init__(*args, **kwargs)\n\n    def __str__(self):\n        return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text\n\n\n# Lazy translation handling\ndef gettext_lazy(text):\n    \"\"\"A dummy function that can be referenced.\"\"\"\n\n    return LazyTranslation(text=text)\n\n\n# Identify our Translatable content\nTranslatable = Union[str, LazyTranslation]\n"
  },
  {
    "path": "apprise/logger.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nfrom io import StringIO\nimport logging\nimport os\n\n# The root identifier needed to monitor 'apprise' logging\nLOGGER_NAME = \"apprise\"\n\n# Define a verbosity level that is a noisier then debug mode\nlogging.TRACE = logging.DEBUG - 1\n\n# Define a verbosity level that is always used even when no verbosity is set\n# from the command line.  The idea here is to allow for deprecation notices\nlogging.DEPRECATE = logging.ERROR + 1\n\n# Assign our Levels into our logging object\nlogging.addLevelName(logging.DEPRECATE, \"DEPRECATION WARNING\")\nlogging.addLevelName(logging.TRACE, \"TRACE\")\n\n\ndef trace(self, message, *args, **kwargs):\n    \"\"\"\n    Verbose Debug Logging - Trace\n    \"\"\"\n    if self.isEnabledFor(logging.TRACE):\n        self._log(logging.TRACE, message, args, **kwargs)\n\n\ndef deprecate(self, message, *args, **kwargs):\n    \"\"\"Deprication Warning Logging.\"\"\"\n    if self.isEnabledFor(logging.DEPRECATE):\n        self._log(logging.DEPRECATE, message, args, **kwargs)\n\n\n# Assign our Loggers for use in Apprise\nlogging.Logger.trace = trace\nlogging.Logger.deprecate = deprecate\n\n# Create ourselve a generic (singleton) logging reference\nlogger = logging.getLogger(LOGGER_NAME)\n\n\nclass LogCapture:\n    \"\"\"A class used to allow one to instantiate loggers that write to memory\n    for temporary purposes. e.g.:\n\n    1.  with LogCapture() as captured:\n    2.\n    3.      # Send our notification(s)\n    4.      aobj.notify(\"hello world\")\n    5.\n    6.      # retrieve our logs produced by the above call via our\n    7.      # `captured` StringIO object we have access to within the `with`\n    8.      # block here:\n    9.      print(captured.getvalue())\n    \"\"\"\n\n    def __init__(\n        self,\n        path=None,\n        level=None,\n        name=LOGGER_NAME,\n        delete=True,\n        fmt=\"%(asctime)s - %(levelname)s - %(message)s\",\n    ):\n        \"\"\"Instantiate a temporary log capture object.\n\n        If a path is specified, then log content is sent to that file instead\n        of a StringIO object.\n\n        You can optionally specify a logging level such as logging.INFO if you\n        wish, otherwise by default the script uses whatever logging has been\n        set globally. If you set delete to `False` then when using log files,\n        they are not automatically cleaned up afterwards.\n\n        Optionally over-ride the fmt as well if you wish.\n        \"\"\"\n        # Our memory buffer placeholder\n        self.__buffer_ptr = StringIO()\n\n        # Store our file path as it will determine whether or not we write to\n        # memory and a file\n        self.__path = path\n        self.__delete = delete\n\n        # Our logging level tracking\n        self.__level = level\n        self.__restore_level = None\n\n        # Acquire a pointer to our logger\n        self.__logger = logging.getLogger(name)\n\n        # Prepare our handler\n        self.__handler = (\n            logging.StreamHandler(self.__buffer_ptr)\n            if not self.__path\n            else logging.FileHandler(self.__path, mode=\"a\", encoding=\"utf-8\")\n        )\n\n        # Use the specified level, otherwise take on the already\n        # effective level of our logger\n        self.__handler.setLevel(\n            self.__level\n            if self.__level is not None\n            else self.__logger.getEffectiveLevel()\n        )\n\n        # Prepare our formatter\n        self.__handler.setFormatter(logging.Formatter(fmt))\n\n    def __enter__(self):\n        \"\"\"Allows logger manipulation within a 'with' block.\"\"\"\n\n        if self.__level is not None:\n            # Temporary adjust our log level if required\n            self.__restore_level = self.__logger.getEffectiveLevel()\n            if self.__restore_level > self.__level:\n                # Bump our log level up for the duration of our `with`\n                self.__logger.setLevel(self.__level)\n\n            else:\n                # No restoration required\n                self.__restore_level = None\n\n        else:\n            # Do nothing but enforce that we have nothing to restore to\n            self.__restore_level = None\n\n        if self.__path:\n            # If a path has been identified, ensure we can write to the path\n            # and that the file exists\n            with open(self.__path, \"a\"):\n                os.utime(self.__path, None)\n\n            # Update our buffer pointer\n            self.__buffer_ptr = open(self.__path)\n\n        # Add our handler\n        self.__logger.addHandler(self.__handler)\n\n        # return our memory pointer\n        return self.__buffer_ptr\n\n    def __exit__(self, exc_type, exc_value, tb):\n        \"\"\"Removes the handler gracefully when the with block has completed.\"\"\"\n\n        # Flush our content\n        self.__handler.flush()\n        self.__buffer_ptr.flush()\n\n        # Drop our handler\n        self.__logger.removeHandler(self.__handler)\n\n        if self.__restore_level is not None:\n            # Restore level\n            self.__logger.setLevel(self.__restore_level)\n\n        if self.__path:\n            # Close our file pointer\n            self.__buffer_ptr.close()\n            self.__handler.close()\n            if self.__delete:\n                with contextlib.suppress(OSError):\n                    # Always remove file afterwards\n                    os.unlink(self.__path)\n\n        return exc_type is None\n"
  },
  {
    "path": "apprise/manager.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nimport hashlib\nimport inspect\nimport os\nfrom os.path import abspath, dirname, join\nimport re\nimport sys\nimport threading\nimport time\n\nfrom .logger import logger\nfrom .utils.disk import path_decode\nfrom .utils.module import import_module\nfrom .utils.parse import parse_list\nfrom .utils.singleton import Singleton\n\n\nclass PluginManager(metaclass=Singleton):\n    \"\"\"Designed to be a singleton object to maintain all initialized loading of\n    modules in memory.\"\"\"\n\n    # Description (used for logging)\n    name = \"Singleton Plugin\"\n\n    # Memory Space\n    _id = \"undefined\"\n\n    # Our Module Python path name\n    module_name_prefix = f\"apprise.{_id}\"\n\n    # The module path to scan\n    module_path = join(abspath(dirname(__file__)), _id)\n\n    # For filtering our result when scanning a module\n    module_filter_re = re.compile(r\"^(?P<name>((?!_)[A-Za-z0-9]+))$\")\n\n    # thread safe loading\n    _lock = threading.Lock()\n\n    def __init__(self, *args, **kwargs):\n        \"\"\"Over-ride our class instantiation to provide a singleton.\"\"\"\n\n        self._module_map = None\n        self._schema_map = None\n\n        # This contains a mapping of all plugins dynamicaly loaded at runtime\n        # from external modules such as the @notify decorator\n        #\n        # The elements here will be additionally added to the _schema_map if\n        # there is no conflict otherwise.\n        # The structure looks like the following:\n        # Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py\n        # {\n        #   'path': path,\n        #\n        #   'notify': {\n        #     'schema': {\n        #       'name': 'Custom schema name',\n        #       'fn_name': 'name_of_function_decorator_was_found_on',\n        #       'url': 'schema://any/additional/info/found/on/url'\n        #       'plugin': <CustomNotifyWrapperPlugin>\n        #    },\n        #     'schema2': {\n        #       'name': 'Custom schema name',\n        #       'fn_name': 'name_of_function_decorator_was_found_on',\n        #       'url': 'schema://any/additional/info/found/on/url'\n        #       'plugin': <CustomNotifyWrapperPlugin>\n        #    }\n        #  }\n        # Note: that the <CustomNotifyWrapperPlugin> inherits from\n        #       NotifyBase\n        self._custom_module_map = {}\n\n        # Track manually disabled modules (by their schema)\n        self._disabled = set()\n\n        # Hash of all paths previously scanned so we don't waste\n        # effort/overhead doing it again\n        self._paths_previously_scanned = set()\n\n        # Track loaded module paths to prevent from loading them again\n        self._loaded = set()\n\n    def unload_modules(self, disable_native=False):\n        \"\"\"Reset our object and unload all modules.\"\"\"\n\n        with self._lock:\n            if self._custom_module_map:\n                # Handle Custom Module Assignments\n                for meta in self._custom_module_map.values():\n                    if meta[\"name\"] not in self._module_map:\n                        # Nothing to remove\n                        continue\n\n                    # For the purpose of tidying up un-used modules in memory\n                    loaded = [\n                        m\n                        for m in sys.modules\n                        if m.startswith(self._module_map[meta[\"name\"]][\"path\"])\n                    ]\n\n                    for module_path in loaded:\n                        del sys.modules[module_path]\n\n            # Reset disabled plugins (if any)\n            for schema in self._disabled:\n                self._schema_map[schema].enabled = True\n            self._disabled.clear()\n\n            # Reset our variables\n            self._schema_map = {}\n            self._custom_module_map = {}\n            if disable_native:\n                self._module_map = {}\n\n            else:\n                self._module_map = None\n                self._loaded = set()\n\n            # Reset our path cache\n            self._paths_previously_scanned = set()\n\n    def load_modules(self, path=None, name=None, force=False):\n        \"\"\"Load our modules into memory.\"\"\"\n\n        # Default value\n        module_name_prefix = self.module_name_prefix if name is None else name\n        module_path = self.module_path if path is None else path\n\n        with self._lock:\n            if not force and module_path in self._loaded:\n                # We're done\n                return\n\n            # Our base reference\n            module_count = len(self._module_map) if self._module_map else 0\n            schema_count = len(self._schema_map) if self._schema_map else 0\n\n            if not self:\n                # Initialize our maps\n                self._module_map = {}\n                self._schema_map = {}\n                self._custom_module_map = {}\n\n            # Used for the detection of additional Notify Services objects\n            # The .py extension is optional as we support loading directories\n            # too\n            module_re = re.compile(\n                r\"^(?P<name>(?!base|_)[a-z0-9_]+)(\\.py)?$\", re.I\n            )\n\n            t_start = time.time()\n            for f in os.listdir(module_path):\n                tl_start = time.time()\n                match = module_re.match(f)\n                if not match:\n                    # keep going\n                    continue\n\n                # Store our notification/plugin name:\n                module_name = match.group(\"name\")\n                module_pyname = f\"{module_name_prefix}.{module_name}\"\n\n                if module_name in self._module_map:\n                    logger.warning(\n                        \"%s(s) (%s) already loaded; ignoring %s\",\n                        self.name,\n                        module_name,\n                        os.path.join(module_path, f),\n                    )\n                    continue\n\n                try:\n                    module = __import__(\n                        module_pyname,\n                        globals(),\n                        locals(),\n                        fromlist=[module_name],\n                    )\n\n                except ImportError:\n                    # No problem, we can try again another way...\n                    module = import_module(\n                        os.path.join(module_path, f), module_pyname\n                    )\n                    if not module:\n                        # logging found in import_module and not needed here\n                        continue\n\n                module_class = None\n                for m_class in [\n                    obj\n                    for obj in dir(module)\n                    if self.module_filter_re.match(obj)\n                ]:\n                    # Get our plugin\n                    plugin = getattr(module, m_class)\n                    if not hasattr(plugin, \"app_id\"):\n                        # Filter out non-notification modules\n                        logger.trace(\n                            \"(%s.%s) import failed; no app_id defined in %s\",\n                            self.name,\n                            m_class,\n                            os.path.join(module_path, f),\n                        )\n                        continue\n\n                    # Add our plugin name to our module map\n                    self._module_map[module_name] = {\n                        \"plugin\": {plugin},\n                        \"module\": module,\n                        \"path\": f\"{module_name_prefix}.{module_name}\",\n                        \"native\": True,\n                    }\n\n                    fn = getattr(plugin, \"schemas\", None)\n                    schemas = set() if not callable(fn) else fn(plugin)\n\n                    # map our schema to our plugin\n                    for schema in schemas:\n                        if schema in self._schema_map:\n                            logger.error(\n                                f\"{self.name} schema ({schema}) mismatch\"\n                                \" detected -\"\n                                f\" {self._schema_map[schema]} already maps to\"\n                                f\" {plugin}\"\n                            )\n                            continue\n\n                        # Assign plugin\n                        self._schema_map[schema] = plugin\n\n                    # Store our class\n                    module_class = m_class\n                    break\n\n                if not module_class:\n                    # Not a library we can load as it doesn't follow the simple\n                    # rule that the class must bear the same name as the\n                    # notification file itself.\n                    logger.trace(\n                        \"%s (%s) import failed; no filename/Class \"\n                        \"match found in %s\",\n                        self.name,\n                        module_name,\n                        os.path.join(module_path, f),\n                    )\n                    continue\n\n                logger.trace(\n                    f\"{self.name} {module_name} loaded in\"\n                    f\" {time.time() - tl_start:.6f}s\"\n                )\n\n            # Track the directory loaded so we never load it again\n            self._loaded.add(module_path)\n\n            logger.debug(\n                f\"{len(self._module_map) - module_count} {self.name}(s) and\"\n                f\" {len(self._schema_map) - schema_count} Schema(s) loaded in\"\n                f\" {time.time() - t_start:.4f}s\"\n            )\n\n    def module_detection(self, paths, cache=True):\n        \"\"\"Leverage the @notify decorator and load all objects found matching\n        this.\"\"\"\n        # A simple restriction that we don't allow periods in the filename at\n        # all so it can't be hidden (Linux OS's) and it won't conflict with\n        # Python path naming.  This also prevents us from loading any python\n        # file that starts with an underscore or dash\n        # We allow for __init__.py as well\n        module_re = re.compile(\n            r\"^(?P<name>[_a-z0-9][a-z0-9._-]+)?(\\.py)?$\", re.I\n        )\n\n        # Validate if we're a loadable Python file or not\n        valid_python_file_re = re.compile(r\".+\\.py(o|c)?$\", re.IGNORECASE)\n\n        if isinstance(paths, str):\n            paths = [\n                paths,\n            ]\n\n        if not paths or not isinstance(paths, (tuple, list)):\n            # We're done\n            return\n\n        def _import_module(path):\n            # Since our plugin name can conflict (as a module) with another\n            # we want to generate random strings to avoid steping on\n            # another's namespace\n            if not (path and valid_python_file_re.match(path)):\n                # Ignore file/module type\n                logger.trace(\"Plugin Scan: Skipping %s\", path)\n                return\n\n            t_start = time.time()\n            module_name = hashlib.sha1(path.encode(\"utf-8\")).hexdigest()\n            module_pyname = \"{prefix}.{name}\".format(\n                prefix=\"apprise.custom.module\", name=module_name\n            )\n\n            if module_pyname in self._custom_module_map:\n                # First clear out existing entries\n                for schema in self._custom_module_map[module_pyname][\"notify\"]:\n\n                    # Remove any mapped modules to this file\n                    del self._schema_map[schema]\n\n                # Reset\n                del self._custom_module_map[module_pyname]\n\n            # Load our module\n            module = import_module(path, module_pyname)\n            if not module:\n                # No problem, we can't use this object\n                logger.warning(\"Failed to load custom module: %s\", path_)\n                return\n\n            # Print our loaded modules if any\n            if module_pyname in self._custom_module_map:\n                logger.debug(\n                    \"Custom module %s - %d schema(s) (name=%s) \"\n                    \"loaded in %.6fs\",\n                    path_,\n                    len(self._custom_module_map[module_pyname][\"notify\"]),\n                    module_name,\n                    (time.time() - t_start),\n                )\n\n                # Add our plugin name to our module map\n                self._module_map[module_name] = {\n                    \"plugin\": set(),\n                    \"module\": module,\n                    \"path\": module_pyname,\n                    \"native\": False,\n                }\n\n                for schema, _meta in self._custom_module_map[module_pyname][\n                    \"notify\"\n                ].items():\n\n                    # For mapping purposes; map our element in our main list\n                    self._module_map[module_name][\"plugin\"].add(\n                        self._schema_map[schema]\n                    )\n\n                    # Log our success\n                    logger.info(\"Loaded custom notification: %s://\", schema)\n            else:\n                # The code reaches here if we successfully loaded the Python\n                # module but no hooks/triggers were found. So we can safely\n                # just remove/ignore this entry\n                del sys.modules[module_pyname]\n                return\n\n            # end of _import_module()\n            return\n\n        for path_ in paths:\n            path = path_decode(path_)\n            if (\n                cache and path in self._paths_previously_scanned\n            ) or not os.path.exists(path):\n                # We're done as we've already scanned this\n                continue\n\n            # Store our path as a way of hashing it has been handled\n            self._paths_previously_scanned.add(path)\n\n            if os.path.isdir(path) and not os.path.isfile(\n                os.path.join(path, \"__init__.py\")\n            ):\n\n                logger.debug(\"Scanning for custom plugins in: %s\", path)\n                for entry in os.listdir(path):\n                    re_match = module_re.match(entry)\n                    if not re_match:\n                        # keep going\n                        logger.trace(\"Plugin Scan: Ignoring %s\", entry)\n                        continue\n\n                    new_path = os.path.join(path, entry)\n                    if os.path.isdir(new_path):\n                        # Update our path\n                        new_path = os.path.join(path, entry, \"__init__.py\")\n                        if not os.path.isfile(new_path):\n                            logger.trace(\n                                \"Plugin Scan: Ignoring %s\",\n                                os.path.join(path, entry),\n                            )\n                            continue\n\n                    if not cache or (\n                        cache\n                        and new_path not in self._paths_previously_scanned\n                    ):\n                        # Load our module\n                        _import_module(new_path)\n\n                        # Add our subdir path\n                        self._paths_previously_scanned.add(new_path)\n            else:\n                if os.path.isdir(path):\n                    # This logic is safe to apply because we already\n                    # validated the directories state above; update our\n                    # path\n                    path = os.path.join(path, \"__init__.py\")\n                    if cache and path in self._paths_previously_scanned:\n                        continue\n\n                    self._paths_previously_scanned.add(path)\n\n                # directly load as is\n                re_match = module_re.match(os.path.basename(path))\n                # must be a match and must have a .py extension\n                if not re_match or not re_match.group(1):\n                    # keep going\n                    logger.trace(\"Plugin Scan: Ignoring %s\", path)\n                    continue\n\n                # Load our module\n                _import_module(path)\n\n        return None\n\n    def add(self, plugin, schemas=None, url=None, send_func=None, force=False):\n        \"\"\"Ability to manually add Notification services to our stack.\"\"\"\n\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        # Acquire a list of schemas\n        p_schemas = parse_list(plugin.secure_protocol, plugin.protocol)\n        if isinstance(schemas, str):\n            schemas = [\n                schemas,\n            ]\n\n        elif schemas is None:\n            # Default\n            schemas = p_schemas\n\n        if not schemas or not isinstance(schemas, (set, tuple, list)):\n            # We're done\n            logger.error(\n                \"The schemas provided (type %s) is unsupported; \"\n                \"loaded from %s.\",\n                type(schemas),\n                send_func.__name__ if send_func else plugin.__class__.__name__,\n            )\n            return False\n\n        # Convert our schemas into a set\n        schemas = {s.lower() for s in schemas} | set(p_schemas)\n\n        # Valdation\n        conflict = [s for s in schemas if s in self]\n        if conflict:\n            if force:\n                # Force implies that we unmap any conflicting schema entries\n                # at the Apprise level, but we do not unload any previously\n                # imported modules. This ensures other classes can safely\n                # subclass from prior notify classes.\n                logger.debug(\n                    \"The schema(s) (%s) are already defined and will be \"\n                    \"force loaded; overriding %s%s.\",\n                    \", \".join(conflict),\n                    \"custom notify function \" if send_func else \"\",\n                    send_func.__name__ if send_func\n                    else plugin.__class__.__name__,\n                )\n                self.remove(*conflict, unload=False)\n\n            else:\n                logger.warning(\n                    \"The schema(s) (%s) are already defined and could not be \"\n                    \"loaded from %s%s.\",\n                    \", \".join(conflict),\n                    \"custom notify function \" if send_func else \"\",\n                    send_func.__name__ if send_func\n                    else plugin.__class__.__name__,\n                )\n                return False\n\n            # Re-check for conflicts after unmapping\n            conflict = [s for s in schemas if s in self]\n            if conflict:\n                logger.warning(\n                    \"The schema(s) (%s) are already defined and could not be \"\n                    \"loaded from %s%s.\",\n                    \", \".join(conflict),\n                    \"custom notify function \" if send_func else \"\",\n                    send_func.__name__ if send_func\n                    else plugin.__class__.__name__,\n                )\n                return False\n\n        if send_func:\n            # Acquire the function name\n            fn_name = send_func.__name__\n\n            # Acquire the python filename path\n            path = inspect.getfile(send_func)\n\n            # Acquire our path to our module\n            module_name = str(send_func.__module__)\n\n            if module_name not in self._custom_module_map:\n                # Support non-dynamic includes as well...\n                self._custom_module_map[module_name] = {\n                    # Name can be useful for indexing back into the\n                    # _module_map object; this is the key to do it with:\n                    \"name\": module_name.split(\".\")[-1],\n                    # The path to the module loaded\n                    \"path\": path,\n                    # Initialize our template\n                    \"notify\": {},\n                }\n\n            for schema in schemas:\n                self._custom_module_map[module_name][\"notify\"][schema] = {\n                    # The name of the send function the @notify decorator\n                    # wrapped\n                    \"fn_name\": fn_name,\n                    # The URL that was provided in the @notify decorator call\n                    # associated with the 'on='\n                    \"url\": url,\n                }\n\n        else:\n            module_name = hashlib.sha1(\n                \"\".join(schemas).encode(\"utf-8\")\n            ).hexdigest()\n            module_pyname = \"{prefix}.{name}\".format(\n                prefix=\"apprise.adhoc.module\", name=module_name\n            )\n\n            # Add our plugin name to our module map\n            self._module_map[module_name] = {\n                \"plugin\": {plugin},\n                \"module\": None,\n                \"path\": module_pyname,\n                \"native\": False,\n            }\n\n        for schema in schemas:\n            # Assign our mapping\n            self._schema_map[schema] = plugin\n\n        return True\n\n    def remove(self, *schemas, unload=True):\n        \"\"\"Removes a loaded element (if defined)\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        for schema in schemas:\n            with contextlib.suppress(KeyError):\n                self._unmap_schema(schema, unload=unload)\n\n    def plugins(self, include_disabled=True):\n        \"\"\"Return all of our loaded plugins.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        for module in self._module_map.values():\n            for plugin in module[\"plugin\"]:\n                if not include_disabled and not plugin.enabled:\n                    continue\n                yield plugin\n\n    def schemas(self, include_disabled=True):\n        \"\"\"Return all of our loaded schemas.\n\n        if include_disabled == True, then even disabled notifications are\n        returned\n        \"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        # Return our list\n        return (\n            list(self._schema_map.keys())\n            if include_disabled\n            else [s for s in self._schema_map if self._schema_map[s].enabled]\n        )\n\n    def disable(self, *schemas):\n        \"\"\"Disables the modules associated with the specified schemas.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        for schema in schemas:\n            if schema not in self._schema_map:\n                continue\n\n            if not self._schema_map[schema].enabled:\n                continue\n\n            # Disable\n            self._schema_map[schema].enabled = False\n            self._disabled.add(schema)\n\n    def enable_only(self, *schemas):\n        \"\"\"Disables the modules associated with the specified schemas.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        # convert to set for faster indexing\n        schemas = set(schemas)\n\n        for plugin in self.plugins():\n            # Get our plugin's schema list\n            p_schemas = set(\n                parse_list(plugin.secure_protocol, plugin.protocol)\n            )\n\n            if not schemas & p_schemas:\n                if plugin.enabled:\n                    # Disable it (only if previously enabled); this prevents us\n                    # from adjusting schemas that were disabled due to missing\n                    # libraries or other environment reasons\n                    plugin.enabled = False\n                    self._disabled |= p_schemas\n                continue\n\n            # If we reach here, our schema was flagged to be enabled\n            if p_schemas & self._disabled:\n                # Previously disabled; no worries, let's clear this up\n                self._disabled -= p_schemas\n                plugin.enabled = True\n\n    def __contains__(self, schema):\n        \"\"\"Checks if a schema exists.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        return schema in self._schema_map\n\n    def __delitem__(self, schema):\n        \"\"\"\n        removes schema map and also unloads it from memory\n        \"\"\"\n        self._unmap_schema(schema, unload=True)\n\n    def __setitem__(self, schema, plugin):\n        \"\"\"Support fast assigning of Plugin/Notification Objects.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        # Set default values if not otherwise set\n        if not plugin.service_name:\n            # Assign service name if one doesn't exist\n            plugin.service_name = f\"{schema}://\"\n\n        p_schemas = set(parse_list(plugin.secure_protocol, plugin.protocol))\n        if not p_schemas:\n            # Assign our protocol\n            plugin.secure_protocol = schema\n            p_schemas.add(schema)\n\n        elif schema not in p_schemas:\n            # Add our others (if defined)\n            plugin.secure_protocol = {\n                schema,\n                *parse_list(plugin.secure_protocol),\n            }\n            p_schemas.add(schema)\n\n        if not self.add(plugin, schemas=p_schemas):\n            raise KeyError(\"Conflicting Assignment\")\n\n    def _unmap_schema(self, schema, *, unload=True):\n        \"\"\"Unmap a schema entry without necessarily unloading modules.\n\n        This function removes the schema mapping and updates internal cross\n        references. When unload is True (default), modules are removed from\n        sys.modules when they are no longer referenced by Apprise. When unload\n        is False, the unmapping is performed but any imported modules remain\n        intact in sys.modules.\n        \"\"\"\n\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        # Get our plugin (otherwise we throw a KeyError) which is intended on\n        # unmap action that doesn't align.\n        plugin = self._schema_map[schema]\n\n        # Our list of all schema entries\n        p_schemas = {schema}\n\n        for key in list(self._module_map.keys()):\n            if plugin in self._module_map[key][\"plugin\"]:\n                # Remove our plugin\n                self._module_map[key][\"plugin\"].remove(plugin)\n\n                # Custom Plugin Entry; Clean up cross reference\n                module_pyname = self._module_map[key][\"path\"]\n                if (\n                    not self._module_map[key][\"native\"]\n                    and module_pyname in self._custom_module_map\n                ):\n\n                    del self._custom_module_map[module_pyname][\n                        \"notify\"][schema]\n\n                    if not self._custom_module_map[module_pyname][\"notify\"]:\n                        #\n                        # Last custom loaded element\n                        #\n\n                        # Free up custom object entry\n                        del self._custom_module_map[module_pyname]\n\n                if not self._module_map[key][\"plugin\"]:\n                    #\n                    # Last element\n                    #\n                    if self._module_map[key][\"native\"]:\n                        # Get our plugin's schema list\n                        p_schemas = {\n                            s\n                            for s in parse_list(\n                                plugin.secure_protocol, plugin.protocol\n                            )\n                            if s in self._schema_map\n                        }\n\n                    # Free system memory only when unload=True\n                    if unload and self._module_map[key][\"module\"]:\n                        with contextlib.suppress(KeyError):\n                            del sys.modules[self._module_map[key][\"path\"]]\n\n                    # Free last remaining pointer in module map\n                    del self._module_map[key]\n\n        for schema in p_schemas:\n            # Final tidy\n            del self._schema_map[schema]\n\n    def __getitem__(self, schema):\n        \"\"\"Returns the indexed plugin identified by the schema specified.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        return self._schema_map[schema]\n\n    def __iter__(self):\n        \"\"\"Returns an iterator so we can iterate over our loaded modules.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        return iter(self._module_map.values())\n\n    def __len__(self):\n        \"\"\"Returns the number of modules/plugins loaded.\"\"\"\n        if not self:\n            # Lazy load\n            self.load_modules()\n\n        return len(self._module_map)\n\n    def __bool__(self):\n        \"\"\"Determines if object has loaded or not.\"\"\"\n        return bool(self._loaded and self._module_map is not None)\n"
  },
  {
    "path": "apprise/manager_attachment.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom os.path import abspath, dirname, join\nimport re\n\nfrom .manager import PluginManager\n\n\nclass AttachmentManager(PluginManager):\n    \"\"\"Designed to be a singleton object to maintain all initialized attachment\n    plugins/modules in memory.\"\"\"\n\n    # Description (used for logging)\n    name = \"Attachment Plugin\"\n\n    # Filename Prefix to filter on\n    fname_prefix = \"Attach\"\n\n    # Memory Space\n    _id = \"attachment\"\n\n    # Our Module Python path name\n    module_name_prefix = f\"apprise.{_id}\"\n\n    # The module path to scan\n    module_path = join(abspath(dirname(__file__)), _id)\n\n    # For filtering our result set\n    module_filter_re = re.compile(\n        r\"^(?P<name>\" + fname_prefix + r\"(?!Base)[A-Za-z0-9]+)$\"\n    )\n"
  },
  {
    "path": "apprise/manager_config.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom os.path import abspath, dirname, join\nimport re\n\nfrom .manager import PluginManager\n\n\nclass ConfigurationManager(PluginManager):\n    \"\"\"Designed to be a singleton object to maintain all initialized\n    configuration plugins/modules in memory.\"\"\"\n\n    # Description (used for logging)\n    name = \"Configuration Plugin\"\n\n    # Filename Prefix to filter on\n    fname_prefix = \"Config\"\n\n    # Memory Space\n    _id = \"config\"\n\n    # Our Module Python path name\n    module_name_prefix = f\"apprise.{_id}\"\n\n    # The module path to scan\n    module_path = join(abspath(dirname(__file__)), _id)\n\n    # For filtering our result set\n    module_filter_re = re.compile(\n        r\"^(?P<name>\" + fname_prefix + r\"(?!Base)[A-Za-z0-9]+)$\"\n    )\n"
  },
  {
    "path": "apprise/manager_plugins.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom os.path import abspath, dirname, join\nimport re\n\nfrom .manager import PluginManager\n\n\nclass NotificationManager(PluginManager):\n    \"\"\"Designed to be a singleton object to maintain all initialized\n    notifications in memory.\"\"\"\n\n    # Description (used for logging)\n    name = \"Notification Plugin\"\n\n    # Filename Prefix to filter on\n    fname_prefix = \"Notify\"\n\n    # Memory Space\n    _id = \"plugins\"\n\n    # Our Module Python path name\n    module_name_prefix = f\"apprise.{_id}\"\n\n    # The module path to scan\n    module_path = join(abspath(dirname(__file__)), _id)\n\n    # For filtering our result set\n    module_filter_re = re.compile(\n        r\"^(?P<name>\" + fname_prefix + r\"(?!Base|ImageSize|Type)[A-Za-z0-9]+)$\"\n    )\n"
  },
  {
    "path": "apprise/persistent_store.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport base64\nimport binascii\nimport builtins\nimport contextlib\nfrom datetime import datetime, timedelta, timezone\nimport glob\nimport gzip\nimport hashlib\nfrom itertools import chain\nimport json\nimport os\nimport re\nimport tempfile\nimport time\nfrom typing import Any, Optional, Union\nimport zlib\n\nfrom . import exception\nfrom .common import (\n    AWARE_DATE_ISO_FORMAT,\n    NAIVE_DATE_ISO_FORMAT,\n    PersistentStoreMode,\n)\nfrom .logger import logger\nfrom .utils.disk import path_decode\n\n# Used for writing/reading time stored in cache file\nEPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)\n\n\ndef _ntf_tidy(ntf):\n    \"\"\"Reusable NamedTemporaryFile Cleanup.\"\"\"\n    if ntf:\n        # Cleanup\n        with contextlib.suppress(OSError):\n            ntf.close()\n\n        try:\n            os.unlink(ntf.name)\n            logger.trace(\"Persistent temporary file removed: %s\", ntf.name)\n\n        except (FileNotFoundError, AttributeError):\n            # AttributeError: something weird was passed in, no action required\n            # FileNotFound: no worries; we were removing it anyway\n            pass\n\n        except OSError as e:\n            logger.error(\n                \"Persistent temporary file removal failed: %s\", ntf.name\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n\nclass CacheObject:\n\n    hash_engine = hashlib.sha256\n    hash_length = 6\n\n    def __init__(\n        self,\n        value: Any = None,\n        expires: Union[bool, float, int, datetime, None] = False,\n        persistent: bool = True,\n    ) -> None:\n        \"\"\"Tracks our objects and associates a time limit with them.\"\"\"\n\n        self.__value = value\n        self.__class_name = value.__class__.__name__\n        self.__expires = None\n\n        if expires:\n            self.set_expiry(expires)\n\n        # Whether or not we persist this object to disk or not\n        self.__persistent = bool(persistent)\n\n    def set(\n        self,\n        value: Any,\n        expires: Union[bool, float, int, datetime, None] = None,\n        persistent: Optional[bool] = None,\n    ) -> None:\n        \"\"\"Sets fields on demand, if set to none, then they are left as is.\n\n        The intent of set is that it allows you to set a new a value and\n        optionally alter meta information against it.\n\n        If expires or persistent isn't specified then their previous values are\n        used.\n        \"\"\"\n\n        self.__value = value\n        self.__class_name = value.__class__.__name__\n        if expires is not None:\n            self.set_expiry(expires)\n\n        if persistent is not None:\n            self.__persistent = bool(persistent)\n\n    def set_expiry(self, expires:\n                   Union[datetime, bool, float, int, None] = None) -> None:\n        \"\"\"Sets a new expiry.\"\"\"\n\n        if isinstance(expires, datetime):\n            self.__expires = expires.astimezone(timezone.utc)\n\n        elif expires in (None, False):\n            # Accepted - no expiry\n            self.__expires = None\n\n        elif expires is True:\n            # Force expiry to now\n            self.__expires = datetime.now(tz=timezone.utc)\n\n        elif isinstance(expires, (float, int)):\n            self.__expires = datetime.now(tz=timezone.utc) + timedelta(\n                seconds=expires\n            )\n\n        else:  # Unsupported\n            raise AttributeError(\n                f\"An invalid expiry time ({expires} was specified\"\n            )\n\n    def hash(self) -> str:\n        \"\"\"Our checksum to track the validity of our data.\"\"\"\n        return self.hash_engine(\n            str(self).encode(\"utf-8\"), usedforsecurity=False\n        ).hexdigest()\n\n    def json(self) -> Optional[dict[str, Any]]:\n        \"\"\"Returns our preparable json object.\"\"\"\n\n        return {\n            \"v\": self.__value,\n            \"x\": (\n                (self.__expires - EPOCH).total_seconds()\n                if self.__expires\n                else None\n            ),\n            \"c\": (\n                self.__class_name\n                if not isinstance(self.__value, datetime)\n                else (\n                    \"aware_datetime\"\n                    if self.__value.tzinfo\n                    else \"naive_datetime\"\n                )\n            ),\n            \"!\": self.hash()[: self.hash_length],\n        }\n\n    @staticmethod\n    def instantiate(\n        content: dict[str, Any],\n        persistent: bool = True,\n        verify: bool = True,\n    ) -> Optional[\"CacheObject\"]:\n        \"\"\"Loads back data read in and returns a CacheObject or None if it\n        could not be loaded.\n\n        You can pass in the contents of CacheObject.json() and you'll receive a\n        copy assuming the hash checks okay\n        \"\"\"\n        try:\n            value = content[\"v\"]\n            expires = content[\"x\"]\n            if expires is not None:\n                expires = datetime.fromtimestamp(expires, timezone.utc)\n\n            # Acquire some useful integrity objects\n            class_name = content.get(\"c\", \"\")\n            if not isinstance(class_name, str):\n                raise TypeError(\"Class name not expected string\")\n\n            hashsum = content.get(\"!\", \"\")\n            if not isinstance(hashsum, str):\n                raise TypeError(\"SHA1SUM not expected string\")\n\n        except (TypeError, KeyError) as e:\n            logger.trace(f\"CacheObject could not be parsed from {content}\")\n            logger.trace(\"CacheObject exception: %s\", str(e))\n            return None\n\n        if class_name in (\"aware_datetime\", \"naive_datetime\", \"datetime\"):\n            # If datetime is detected, it will fall under the naive category\n            iso_format = (\n                AWARE_DATE_ISO_FORMAT\n                if class_name[0] == \"a\"\n                else NAIVE_DATE_ISO_FORMAT\n            )\n            try:\n                # Python v3.6 Support\n                value = datetime.strptime(value, iso_format)\n\n            except (TypeError, ValueError):\n                # TypeError is thrown if content is not string\n                # ValueError is thrown if the string is not a valid format\n                logger.trace(\n                    f\"CacheObject (dt) corrupted loading from {content}\"\n                )\n                return None\n\n        elif class_name == \"bytes\":\n            try:\n                # Convert our object back to a bytes\n                value = base64.b64decode(value)\n\n            except binascii.Error:\n                logger.trace(\n                    f\"CacheObject (bin) corrupted loading from {content}\"\n                )\n                return None\n\n        # Initialize our object\n        co = CacheObject(value, expires, persistent=persistent)\n        if verify and co.hash()[: co.hash_length] != hashsum:\n            # Our object was tampered with\n            logger.debug(f\"Tampering detected with cache entry {co}\")\n            del co\n            return None\n\n        return co\n\n    @property\n    def value(self) -> Any:\n        \"\"\"Returns our value.\"\"\"\n        return self.__value\n\n    @property\n    def persistent(self) -> bool:\n        \"\"\"Returns our persistent value.\"\"\"\n        return self.__persistent\n\n    @property\n    def expires(self) -> Optional[datetime]:\n        \"\"\"Returns the datetime the object will expire.\"\"\"\n        return self.__expires\n\n    @property\n    def expires_sec(self) -> Optional[float]:\n        \"\"\"Returns the number of seconds from now the object will expire.\"\"\"\n\n        return (\n            None\n            if self.__expires is None\n            else max(\n                0.0,\n                (\n                    self.__expires - datetime.now(tz=timezone.utc)\n                ).total_seconds(),\n            )\n        )\n\n    def __bool__(self) -> bool:\n        \"\"\"Returns True it the object hasn't expired, and False if it has.\"\"\"\n        if self.__expires is None:\n            # No Expiry\n            return True\n\n        # Calculate if we've expired or not\n        return self.__expires > datetime.now(tz=timezone.utc)\n\n    def __eq__(self, other) -> bool:\n        \"\"\"Handles equality == flag.\"\"\"\n        if isinstance(other, CacheObject):\n            return str(self) == str(other)\n\n        return self.__value == other\n\n    def __str__(self) -> str:\n        \"\"\"String output of our data.\"\"\"\n        persistent = \"+\" if self.persistent else \"-\"\n        return f\"{self.__class_name}:{persistent}:{self.__value} expires: \" + (\n            \"never\"\n            if self.__expires is None\n            else self.__expires.strftime(NAIVE_DATE_ISO_FORMAT)\n        )\n\n\nclass CacheJSONEncoder(json.JSONEncoder):\n    \"\"\"A JSON Encoder for handling each of our cache objects.\"\"\"\n\n    def default(self, entry):\n        if isinstance(entry, datetime):\n            return entry.strftime(\n                AWARE_DATE_ISO_FORMAT\n                if entry.tzinfo is not None\n                else NAIVE_DATE_ISO_FORMAT\n            )\n\n        elif isinstance(entry, CacheObject):\n            return entry.json()\n\n        elif isinstance(entry, bytes):\n            return base64.b64encode(entry).decode(\"utf-8\")\n\n        return super().default(entry)\n\n\nclass PersistentStore:\n    \"\"\"An object to make working with persistent storage easier.\n\n    read() and write() are used for direct file i/o\n\n    set(), get() are used for caching\n    \"\"\"\n\n    # The maximum file-size we will allow the persistent store to grow to\n    # 1 MB = 1048576 bytes\n    max_file_size = 1048576\n\n    # 30 days in seconds\n    default_file_expiry = 2678400\n\n    # File encoding to use\n    encoding = \"utf-8\"\n\n    # Default data set\n    base_key = \"default\"\n\n    # Directory to store cache\n    __cache_key = \"cache\"\n\n    # Our Temporary working directory\n    temp_dir = \"tmp\"\n\n    # The directory our persistent store content gets placed in\n    data_dir = \"var\"\n\n    # Our Persistent Store File Extension\n    __extension = \".psdata\"\n\n    # Identify our backup file extension\n    __backup_extension = \"._psbak\"\n\n    # Used to verify the key specified is valid\n    #  - must start with an alpha_numeric\n    #  - following optional characters can include period, underscore and\n    #    equal\n    __valid_key = re.compile(r\"[a-z0-9][a-z0-9._-]*\", re.I)\n\n    # Reference only\n    __not_found_ref = (None, None)\n\n    def __init__(\n        self,\n        path: Optional[str] = None,\n        namespace: str = \"default\",\n        mode: Optional[Union[str, PersistentStoreMode]] = None,\n    ) -> None:\n        \"\"\"Provide the namespace to work within.\n\n        namespaces can only contain alpha-numeric characters with the exception\n        of '-' (dash), '_' (underscore), and '.' (period). The namespace must\n        be be relative to the current URL being controlled.\n        \"\"\"\n        # Initalize our mode so __del__() calls don't go bad on the\n        # error checking below\n        self.__mode = None\n\n        # Populated only once and after size() is called\n        self.__exclude_list = None\n\n        # Files to renew on calls to flush\n        self.__renew = set()\n\n        if not isinstance(namespace, str) or not self.__valid_key.match(\n            namespace\n        ):\n            raise AttributeError(\n                f\"Persistent Storage namespace ({namespace}) provided is\"\n                \" invalid\"\n            )\n\n        if isinstance(path, str):\n            # A storage path has been defined\n            if mode is None:\n                # Store Default if no mode was provided along side of it\n                mode = PersistentStoreMode.AUTO\n\n            # Store our information\n            self.__base_path = os.path.join(path_decode(path), namespace)\n            self.__temp_path = os.path.join(self.__base_path, self.temp_dir)\n            self.__data_path = os.path.join(self.__base_path, self.data_dir)\n\n        else:  # If no storage path is provide we set our mode to MEMORY\n            mode = PersistentStoreMode.MEMORY\n            self.__base_path = None\n            self.__temp_path = None\n            self.__data_path = None\n\n        # Tracks when we have content to flush\n        self.__dirty = False\n\n        # A caching value to track persistent storage disk size\n        self.__cache_size = None\n        self.__cache_files = {}\n\n        # Internal Cache\n        self._cache = None\n\n        try:\n            # Store our mode\n            self.__mode = (\n                mode if isinstance(mode, PersistentStoreMode)\n                else PersistentStoreMode(mode.lower())\n            )\n\n        except (AttributeError, ValueError):\n            err = (\n                f\"An invalid persistent storage mode ({mode}) was specified.\",\n            )\n            logger.warning(err)\n            raise AttributeError(err) from None\n\n        # Prepare our environment\n        self.__prepare()\n\n    def read(\n        self,\n        key: Optional[str] = None,\n        compress: bool = True,\n        expires: Union[bool, float, int] = False,\n    ) -> Optional[bytes]:\n        \"\"\"Returns the content of the persistent store object.\n\n        if refresh is set to True, then the file's modify time is updated\n        preventing it from getting caught in prune calls.  It's a means of\n        allowing it to persist and not get cleaned up in later prune calls.\n\n        Content is always returned as a byte object\n        \"\"\"\n        try:\n            with self.open(key, mode=\"rb\", compress=compress) as fd:\n                results = fd.read(self.max_file_size)\n                if expires is False:\n                    self.__renew.add(\n                        os.path.join(\n                            self.__data_path, f\"{key}{self.__extension}\"\n                        )\n                    )\n\n                return results\n\n        except (FileNotFoundError, exception.AppriseDiskIOError):\n            # FileNotFoundError: No problem\n            # exception.AppriseDiskIOError:\n            #   - Logging of error already occurred inside self.open()\n            pass\n\n        except (OSError, zlib.error, EOFError, UnicodeDecodeError) as e:\n            # We can't access the file or it does not exist\n            logger.warning(\"Could not read with persistent key: %s\", key)\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n        # return none\n        return None\n\n    def write(\n        self,\n        data: Union[bytes, str, Any],\n        key: Optional[str] = None,\n        compress: bool = True,\n        _recovery: bool = False,\n    ) -> bool:\n        \"\"\"Writes the content to the persistent store if it doesn't exceed our\n        filesize limit.\n\n        Content is always written as a byte object\n\n        _recovery is reserved for internal usage and should not be changed\n        \"\"\"\n\n        if key is None:\n            key = self.base_key\n\n        elif not isinstance(key, str) or not self.__valid_key.match(key):\n            raise AttributeError(\n                f\"Persistent Storage key ({key} provided is invalid\"\n            )\n\n        if not isinstance(data, (bytes, str)):\n            # One last check, we will accept read() objets with the expectation\n            # it will return a binary dataset\n            if not (hasattr(data, \"read\") and callable(data.read)):\n                raise AttributeError(\n                    f\"Invalid data type {type(data)} provided to Persistent\"\n                    \" Storage\"\n                )\n\n            try:\n                # Read in our data\n                data = data.read()\n                if not isinstance(data, (bytes, str)):\n                    raise AttributeError(\n                        f\"Invalid data type {type(data)} provided to\"\n                        \" Persistent Storage\"\n                    )\n\n            except Exception as e:\n                logger.warning(\n                    \"Could read() from potential iostream with persistent \"\n                    \"key: %s\",\n                    key,\n                )\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n                raise exception.AppriseDiskIOError(\n                    f\"Invalid data type {type(data)} provided to Persistent\"\n                    \" Storage\"\n                ) from None\n\n        if self.__mode == PersistentStoreMode.MEMORY:\n            # Nothing further can be done\n            return False\n\n        if _recovery:\n            # Attempt to recover from a bad directory structure or setup\n            self.__prepare()\n\n        # generate our filename based on the key provided\n        io_file = os.path.join(self.__data_path, f\"{key}{self.__extension}\")\n\n        # Calculate the files current filesize\n        try:\n            prev_size = os.stat(io_file).st_size\n\n        except FileNotFoundError:\n            # No worries, no size to accommodate\n            prev_size = 0\n\n        except OSError as e:\n            # Permission error of some kind or disk problem...\n            # There is nothing we can do at this point\n            logger.warning(\"Could not write with persistent key: %s\", key)\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n            return False\n\n        # Create a temporary file to write our content into\n        # ntf = NamedTemporaryFile\n        ntf = None\n        new_file_size = 0\n        try:\n            if isinstance(data, str):\n                data = data.encode(self.encoding)\n\n            ntf = tempfile.NamedTemporaryFile(  # noqa: SIM115\n                mode=\"wb\", dir=self.__temp_path, delete=False\n            )\n\n            # Close our file\n            ntf.close()\n\n            # Pointer to our open call\n            open_ = open if not compress else gzip.open\n\n            with open_(ntf.name, mode=\"wb\") as fd:\n                # Write our content\n                fd.write(data)\n\n            # Get our file size\n            new_file_size = os.stat(ntf.name).st_size\n\n            # Log our progress\n            logger.trace(\n                \"Wrote %d bytes of data to persistent key: %s\",\n                new_file_size,\n                key,\n            )\n\n        except FileNotFoundError:\n            # This happens if the directory path is gone preventing the file\n            # from being created...\n            if not _recovery:\n                return self.write(\n                    data=data, key=key, compress=compress, _recovery=True\n                )\n\n            # We've already made our best effort to recover if we are here in\n            # our code base... we're going to have to exit\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n\n            # Early Exit\n            return False\n\n        except (OSError, UnicodeEncodeError, zlib.error) as e:\n            # We can't access the file or it does not exist\n            logger.warning(\"Could not write to persistent key: %s\", key)\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n\n            return False\n\n        if (\n            self.max_file_size > 0\n            and (new_file_size + self.size() - prev_size) > self.max_file_size\n        ):\n            # The content to store is to large\n            logger.warning(\n                \"Persistent content exceeds allowable maximum file length\"\n                f\" ({int(self.max_file_size / 1024)}KB); provide\"\n                f\" {int(new_file_size / 1024)}KB\"\n            )\n            return False\n\n        # Return our final move\n        if not self.__move(ntf.name, io_file):\n            # Attempt to restore things as they were\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n            return False\n\n        # Resetour reference variables\n        self.__cache_size = None\n        self.__cache_files.clear()\n\n        # Content installed\n        return True\n\n    def __move(self, src, dst):\n        \"\"\"Moves the new file in place and handles the old if it exists already\n        If the transaction fails in any way, the old file is swapped back.\n\n        Function returns True if successful and False if not.\n        \"\"\"\n\n        # A temporary backup of the file we want to move in place\n        dst_backup = (\n            dst[: -len(self.__backup_extension)] + self.__backup_extension\n        )\n\n        #\n        # Backup the old file (if it exists) allowing us to have a restore\n        # point in the event of a failure\n        #\n        try:\n            # make sure the file isn't already present; if it is; remove it\n            os.unlink(dst_backup)\n            logger.trace(\n                \"Removed previous persistent backup file: %s\", dst_backup\n            )\n\n        except FileNotFoundError:\n            # no worries; we were removing it anyway\n            pass\n\n        except OSError as e:\n            # Permission error of some kind or disk problem...\n            # There is nothing we can do at this point\n            logger.warning(\n                \"Could not previous persistent data backup: %s\", dst_backup\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n            return False\n\n        try:\n            # Back our file up so we have a fallback\n            os.rename(dst, dst_backup)\n            logger.trace(\n                \"Persistent storage backup file created: %s\", dst_backup\n            )\n\n        except FileNotFoundError:\n            # Not a problem; this is a brand new file we're writing\n            # There is nothing to backup\n            pass\n\n        except OSError as e:\n            # This isn't good... we couldn't put our new file in place\n            logger.warning(\n                \"Could not install persistent content %s -> %s\",\n                dst,\n                os.path.basename(dst_backup),\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n            return False\n\n        #\n        # Now place the new file\n        #\n        try:\n            os.rename(src, dst)\n            logger.trace(\"Persistent file installed: %s\", dst)\n\n        except OSError as e:\n            # This isn't good... we couldn't put our new file in place\n            # Begin fall-back process before leaving the funtion\n            logger.warning(\n                \"Could not install persistent content %s -> %s\",\n                src,\n                os.path.basename(dst),\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n            try:\n                # Restore our old backup (if it exists)\n                os.rename(dst_backup, dst)\n                logger.trace(\"Restoring original persistent content: %s\", dst)\n\n            except FileNotFoundError:\n                # Not a problem\n                pass\n\n            except OSError as e:\n                # Permission error of some kind or disk problem...\n                # There is nothing we can do at this point\n                logger.warning(\n                    \"Failed to restore original persistent file: %s\", dst\n                )\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n            return False\n\n        return True\n\n    def open(\n        self,\n        key: Optional[str] = None,\n        mode: str = \"r\",\n        buffering: int = -1,\n        encoding: Optional[str] = None,\n        errors: Optional[str] = None,\n        newline: Optional[str] = None,\n        closefd: bool = True,\n        opener: Optional[Any] = None,\n        compress: bool = False,\n        compresslevel: int = 9,\n    ) -> Any:\n        \"\"\"Returns an iterator to our our file within our namespace identified\n        by the key provided.\n\n        If no key is provided, then the default is used\n        \"\"\"\n\n        if key is None:\n            key = self.base_key\n\n        elif not isinstance(key, str) or not self.__valid_key.match(key):\n            raise AttributeError(\n                f\"Persistent Storage key ({key} provided is invalid\"\n            )\n\n        if self.__mode == PersistentStoreMode.MEMORY:\n            # Nothing further can be done\n            raise FileNotFoundError()\n\n        io_file = os.path.join(self.__data_path, f\"{key}{self.__extension}\")\n        try:\n            return (\n                open(\n                    io_file,\n                    mode=mode,\n                    buffering=buffering,\n                    encoding=encoding,\n                    errors=errors,\n                    newline=newline,\n                    closefd=closefd,\n                    opener=opener,\n                )\n                if not compress\n                else gzip.open(\n                    io_file,\n                    compresslevel=compresslevel,\n                    encoding=encoding,\n                    errors=errors,\n                    newline=newline,\n                )\n            )\n\n        except FileNotFoundError:\n            # pass along (but wrap with Apprise exception)\n            raise exception.AppriseFileNotFound(\n                f\"No such file or directory: '{io_file}'\"\n            ) from None\n\n        except (OSError, zlib.error) as e:\n            # We can't access the file or it does not exist\n            logger.warning(\"Could not read with persistent key: %s\", key)\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n            raise exception.AppriseDiskIOError(str(e)) from None\n\n    def get(\n        self,\n        key: str,\n        default: Any = None,\n        lazy: bool = True,\n    ) -> Any:\n        \"\"\"Fetches from cache.\"\"\"\n\n        if self._cache is None and not self.__load_cache():\n            return default\n\n        if (\n            key in self._cache\n            and self.__mode != PersistentStoreMode.MEMORY\n            and not self.__dirty\n        ):\n\n            # ensure we renew our content\n            self.__renew.add(self.cache_file)\n\n        return self._cache[key].value if self._cache.get(key) else default\n\n    def set(\n        self,\n        key: str,\n        value: Any,\n        expires: Union[float, int, datetime, bool, None] = None,\n        persistent: bool = True,\n        lazy: bool = True,\n    ) -> bool:\n        \"\"\"Cache reference.\"\"\"\n\n        if self._cache is None and not self.__load_cache():\n            return False\n\n        cache = CacheObject(value, expires, persistent=persistent)\n        # Fetch our cache value\n        try:\n            if lazy and cache == self._cache[key]:\n                # We're done; nothing further to do\n                return True\n\n        except KeyError:\n            pass\n\n        # Store our new cache\n        self._cache[key] = CacheObject(value, expires, persistent=persistent)\n\n        # Set our dirty flag\n        self.__dirty = persistent\n\n        if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:\n            # Flush changes to disk\n            return self.flush()\n\n        return True\n\n    def clear(self, *args: str) -> Optional[bool]:\n        \"\"\"Remove one or more cache entry by it's key.\n\n            e.g: clear('key')\n                 clear('key1', 'key2', key-12')\n\n        Or clear everything:\n                 clear()\n        \"\"\"\n        if self._cache is None and not self.__load_cache():\n            return False\n\n        if args:\n            for arg in args:\n\n                try:\n                    del self._cache[arg]\n\n                    # Set our dirty flag (if not set already)\n                    self.__dirty = True\n\n                except KeyError:\n                    pass\n\n        elif self._cache:\n            # Request to remove everything and there is something to remove\n\n            # Set our dirty flag (if not set already)\n            self.__dirty = True\n\n            # Reset our object\n            self._cache.clear()\n\n        if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:\n            # Flush changes to disk\n            return self.flush()\n\n    def prune(self) -> bool:\n        \"\"\"Eliminates expired cache entries.\"\"\"\n        if self._cache is None and not self.__load_cache():\n            return False\n\n        change = False\n        for key in list(self._cache.keys()):\n            if key not in self:\n                # It's identified as being expired\n                if not change and self._cache[key].persistent:\n                    # track change only if content was persistent\n                    change = True\n\n                    # Set our dirty flag\n                    self.__dirty = True\n\n                del self._cache[key]\n\n        if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:\n            # Flush changes to disk\n            return self.flush()\n\n        return change\n\n    def __load_cache(self, _recovery=False):\n        \"\"\"Loads our cache.\n\n        _recovery is reserved for internal usage and should not be changed\n        \"\"\"\n\n        # Prepare our dirty flag\n        self.__dirty = False\n\n        if self.__mode == PersistentStoreMode.MEMORY:\n            # Nothing further to do\n            self._cache = {}\n            return True\n\n        # Prepare our cache file\n        cache_file = self.cache_file\n        try:\n            with gzip.open(cache_file, \"rb\") as f:\n                # Read our ontent from disk\n                self._cache = {}\n                for k, v in json.loads(f.read().decode(self.encoding)).items():\n                    co = CacheObject.instantiate(v)\n                    if co:\n                        # Verify our object before assigning it\n                        self._cache[k] = co\n\n                    elif not self.__dirty:\n                        # Track changes from our loadset\n                        self.__dirty = True\n\n        except (\n            UnicodeDecodeError,\n            json.decoder.JSONDecodeError,\n            zlib.error,\n            TypeError,\n            AttributeError,\n            EOFError,\n        ):\n\n            # Let users known there was a problem\n            logger.warning(\n                \"Corrupted access persistent cache content: %s\", cache_file\n            )\n\n            if not _recovery:\n                try:\n                    os.unlink(cache_file)\n                    logger.trace(\n                        \"Removed previous persistent cache content: %s\",\n                        cache_file,\n                    )\n\n                except FileNotFoundError:\n                    # no worries; we were removing it anyway\n                    pass\n\n                except OSError as e:\n                    # Permission error of some kind or disk problem...\n                    # There is nothing we can do at this point\n                    logger.warning(\n                        \"Could not remove persistent cache content: %s\",\n                        cache_file,\n                    )\n                    logger.debug(\"Persistent Storage Exception: %s\", str(e))\n                    return False\n                return self.__load_cache(_recovery=True)\n\n            return False\n\n        except FileNotFoundError:\n            # No problem; no cache to load\n            self._cache = {}\n\n        except OSError as e:\n            # Permission error of some kind or disk problem...\n            # There is nothing we can do at this point\n            logger.warning(\n                \"Could not load persistent cache for namespace %s\",\n                os.path.basename(self.__base_path),\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n            return False\n\n        # Ensure our dirty flag is set to False\n        return True\n\n    def __prepare(self, flush=True):\n        \"\"\"Prepares a working environment.\"\"\"\n        if self.__mode != PersistentStoreMode.MEMORY:\n            # Ensure our path exists\n            try:\n                os.makedirs(self.__base_path, mode=0o770, exist_ok=True)\n\n            except OSError as e:\n                # Permission error\n                logger.debug(\n                    \"Could not create persistent store directory %s\",\n                    self.__base_path,\n                )\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n                # Mode changed back to MEMORY\n                self.__mode = PersistentStoreMode.MEMORY\n\n            # Ensure our path exists\n            try:\n                os.makedirs(self.__temp_path, mode=0o770, exist_ok=True)\n\n            except OSError as e:\n                # Permission error\n                logger.debug(\n                    \"Could not create persistent store directory %s\",\n                    self.__temp_path,\n                )\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n                # Mode changed back to MEMORY\n                self.__mode = PersistentStoreMode.MEMORY\n\n            try:\n                os.makedirs(self.__data_path, mode=0o770, exist_ok=True)\n\n            except OSError as e:\n                # Permission error\n                logger.debug(\n                    \"Could not create persistent store directory %s\",\n                    self.__data_path,\n                )\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n                # Mode changed back to MEMORY\n                self.__mode = PersistentStoreMode.MEMORY\n\n            if self.__mode is PersistentStoreMode.MEMORY:\n                logger.warning(\n                    \"The persistent storage could not be fully initialized; \"\n                    \"operating in MEMORY mode\"\n                )\n\n            else:\n                if self._cache:\n                    # Recovery taking place\n                    self.__dirty = True\n                    logger.warning(\n                        \"The persistent storage environment was disrupted\"\n                    )\n\n                    if self.__mode is PersistentStoreMode.FLUSH and flush:\n                        # Flush changes to disk\n                        return self.flush(_recovery=True)\n\n    def flush(\n        self,\n        force: bool = False,\n        _recovery: bool = False,\n    ) -> bool:\n        \"\"\"Save's our cache to disk.\"\"\"\n\n        if self._cache is None or self.__mode == PersistentStoreMode.MEMORY:\n            # nothing to do\n            return True\n\n        while self.__renew:\n            # update our files\n            path = self.__renew.pop()\n            ftime = time.time()\n\n            try:\n                # (access_time, modify_time)\n                os.utime(path, (ftime, ftime))\n                logger.trace(\"file timestamp updated: %s\", path)\n\n            except FileNotFoundError:\n                # No worries... move along\n                pass\n\n            except OSError as e:\n                # We can't access the file or it does not exist\n                logger.debug(\"Could not update file timestamp: %s\", path)\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n        if not force and self.__dirty is False:\n            # Nothing further to do\n            logger.trace(\"Persistent cache is consistent with memory map\")\n            return True\n\n        if _recovery:\n            # Attempt to recover from a bad directory structure or setup\n            self.__prepare(flush=False)\n\n        # Unset our size lazy setting\n        self.__cache_size = None\n        self.__cache_files.clear()\n\n        # Prepare our cache file\n        cache_file = self.cache_file\n        if not self._cache:\n            #\n            # We're deleting the cache file s there are no entries left in it\n            #\n            backup_file = (\n                cache_file[: -len(self.__backup_extension)]\n                + self.__backup_extension\n            )\n\n            try:\n                os.unlink(backup_file)\n                logger.trace(\n                    \"Removed previous persistent cache backup: %s\", backup_file\n                )\n\n            except FileNotFoundError:\n                # no worries; we were removing it anyway\n                pass\n\n            except OSError as e:\n                # Permission error of some kind or disk problem...\n                # There is nothing we can do at this point\n                logger.warning(\n                    \"Could not remove persistent cache backup: %s\", backup_file\n                )\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n                return False\n\n            try:\n                os.rename(cache_file, backup_file)\n                logger.trace(\n                    \"Persistent cache backup file created: %s\", backup_file\n                )\n\n            except FileNotFoundError:\n                # Not a problem; do not create a log entry\n                pass\n\n            except OSError as e:\n                # This isn't good... we couldn't put our new file in place\n                logger.warning(\n                    \"Could not remove stale persistent cache file: %s\",\n                    cache_file,\n                )\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n                return False\n            return True\n\n        #\n        # If we get here, we need to update our file based cache\n        #\n\n        # ntf = NamedTemporaryFile\n        ntf = None\n\n        try:\n            ntf = tempfile.NamedTemporaryFile(  # noqa: SIM115\n                mode=\"w+\",\n                encoding=self.encoding,\n                dir=self.__temp_path,\n                delete=False,\n            )\n\n            ntf.close()\n\n        except FileNotFoundError:\n            # This happens if the directory path is gone preventing the file\n            # from being created...\n            if not _recovery:\n                return self.flush(force=True, _recovery=True)\n\n            # We've already made our best effort to recover if we are here in\n            # our code base... we're going to have to exit\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n\n            # Early Exit\n            return False\n\n        except OSError as e:\n            logger.error(\n                \"Persistent temporary directory inaccessible: %s\",\n                self.__temp_path,\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n\n            # Early Exit\n            return False\n\n        try:\n            # write our content currently saved to disk to our temporary file\n            with gzip.open(ntf.name, \"wb\") as f:\n                # Write our content to disk\n                f.write(\n                    json.dumps(\n                        {\n                            k: v\n                            for k, v in self._cache.items()\n                            if v and v.persistent\n                        },\n                        separators=(\",\", \":\"),\n                        cls=CacheJSONEncoder,\n                    ).encode(self.encoding)\n                )\n\n        except TypeError as e:\n            # JSON object contains content that can not be encoded to disk\n            logger.error(\n                \"Persistent temporary file can not be written to \"\n                \"due to bad input data: %s\",\n                ntf.name,\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n\n            # Early Exit\n            return False\n\n        except (OSError, EOFError, zlib.error) as e:\n            logger.error(\n                \"Persistent temporary file inaccessible: %s\", ntf.name\n            )\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n\n            # Early Exit\n            return False\n\n        if not self.__move(ntf.name, cache_file):\n            # Attempt to restore things as they were\n\n            # Tidy our Named Temporary File\n            _ntf_tidy(ntf)\n            return False\n\n        # Ensure our dirty flag is set to False\n        self.__dirty = False\n\n        return True\n\n    def files(\n        self,\n        exclude: bool = True,\n        lazy: bool = True,\n    ) -> list[str]:\n        \"\"\"Returns the total files.\"\"\"\n\n        if lazy and exclude in self.__cache_files:\n            # Take an early exit with our cached results\n            return self.__cache_files[exclude]\n\n        elif self.__mode == PersistentStoreMode.MEMORY:\n            # Take an early exit\n            # exclude is our cache switch and can be either True or False.\n            # For the below, we just set both cases and set them up as an\n            # empty record\n            self.__cache_files.update({True: [], False: []})\n            return []\n\n        if not lazy or self.__exclude_list is None:\n            # A list of criteria that should be excluded from the size count\n            self.__exclude_list = (\n                # Exclude backup cache file from count\n                re.compile(\n                    re.escape(\n                        os.path.join(\n                            self.__base_path,\n                            f\"{self.__cache_key}{self.__backup_extension}\",\n                        )\n                    )\n                ),\n                # Exclude temporary files\n                re.compile(re.escape(self.__temp_path) + r\"[/\\\\].+\"),\n                # Exclude custom backup persistent files\n                re.compile(\n                    re.escape(self.__data_path)\n                    + r\"[/\\\\].+\"\n                    + re.escape(self.__backup_extension)\n                ),\n            )\n\n        try:\n            if exclude:\n                self.__cache_files[exclude] = [\n                    path\n                    for path in filter(\n                        os.path.isfile,\n                        glob.glob(\n                            os.path.join(self.__base_path, \"**\", \"*\"),\n                            recursive=True,\n                        ),\n                    )\n                    if next(\n                        (False for p in self.__exclude_list if p.match(path)),\n                        True,\n                    )\n                ]\n\n            else:  # No exclusion list applied\n                self.__cache_files[exclude] = list(\n                    filter(\n                        os.path.isfile,\n                        glob.glob(\n                            os.path.join(self.__base_path, \"**\", \"*\"),\n                            recursive=True,\n                        ),\n                    )\n                )\n\n        except OSError:\n            # We can't access the directory or it does not exist\n            self.__cache_files[exclude] = []\n\n        return self.__cache_files[exclude]\n\n    @staticmethod\n    def disk_scan(\n        path: str,\n        namespace: Optional[Union[str, list[str]]] = None,\n        closest: bool = True,\n    ) -> list[str]:\n        \"\"\"Scansk a path provided and returns namespaces detected.\"\"\"\n\n        logger.trace(\"Persistent path can of: %s\", path)\n\n        def is_namespace(x):\n            \"\"\"Validate what was detected is a valid namespace.\"\"\"\n            return os.path.isdir(\n                os.path.join(path, x)\n            ) and PersistentStore.__valid_key.match(x)\n\n        # Handle our namespace searching\n        if namespace:\n            if isinstance(namespace, str):\n                namespace = [namespace]\n\n            elif not isinstance(namespace, (tuple, set, list)):\n                raise AttributeError(\n                    \"namespace must be None, a string, or a tuple/set/list \"\n                    \"of strings\"\n                )\n\n        try:\n            # Acquire all of the files in question\n            namespaces = (\n                [\n                    ns\n                    for ns in filter(is_namespace, os.listdir(path))\n                    if not namespace\n                    or next(\n                        (True for n in namespace if ns.startswith(n)), False\n                    )\n                ]\n                if closest\n                else [\n                    ns\n                    for ns in filter(is_namespace, os.listdir(path))\n                    if not namespace or ns in namespace\n                ]\n            )\n\n        except FileNotFoundError:\n            # no worries; Nothing to do\n            logger.debug(\"Disk Prune path not found; nothing to clean.\")\n            return []\n\n        except OSError as e:\n            # Permission error of some kind or disk problem...\n            # There is nothing we can do at this point\n            logger.error(\"Disk Scan detetcted inaccessible path: %s\", path)\n            logger.debug(\"Persistent Storage Exception: %s\", str(e))\n            return []\n\n        return namespaces\n\n    @staticmethod\n    def disk_prune(\n        path: str,\n        namespace: Optional[Union[str, list[str]]] = None,\n        expires: Optional[Union[int, float]] = None,\n        action: bool = False,\n    ) -> dict[str, list[dict[str, Union[str, bool]]]]:\n        \"\"\"Prune persistent disk storage entries that are old and/or\n        unreferenced.\n\n        you must specify a path to perform the prune within\n\n        if one or more namespaces are provided, then pruning focuses ONLY on\n        those entries (if matched).\n\n        if action is not set to False, directories to be removed are returned\n        only\n        \"\"\"\n\n        # Prepare our File Expiry\n        expires = (\n            datetime.now() - timedelta(seconds=expires)\n            if isinstance(expires, (float, int)) and expires >= 0\n            else PersistentStore.default_file_expiry\n        )\n\n        # Get our namespaces\n        namespaces = PersistentStore.disk_scan(path, namespace)\n\n        # Track matches\n        map_ = {}\n\n        for namespace in namespaces:\n            # Prepare our map\n            map_[namespace] = []\n\n            # Reference Directories\n            base_dir = os.path.join(path, namespace)\n            data_dir = os.path.join(base_dir, PersistentStore.data_dir)\n            temp_dir = os.path.join(base_dir, PersistentStore.temp_dir)\n\n            # Careful to only focus on files created by this Persistent Store\n            # object\n            files = [\n                os.path.join(\n                    base_dir,\n                    f\"{PersistentStore.__cache_key}\"\n                    f\"{PersistentStore.__extension}\",\n                ),\n                os.path.join(\n                    base_dir,\n                    f\"{PersistentStore.__cache_key}\"\n                    f\"{PersistentStore.__backup_extension}\",\n                ),\n            ]\n\n            # Update our files (applying what was defined above too)\n            valid_data_re = re.compile(\n                r\".*(\"\n                + re.escape(PersistentStore.__extension)\n                + r\"|\"\n                + re.escape(PersistentStore.__backup_extension)\n                + r\")$\"\n            )\n\n            files = [\n                path\n                for path in filter(\n                    os.path.isfile,\n                    chain(\n                        glob.glob(\n                            os.path.join(data_dir, \"*\"), recursive=False\n                        ),\n                        files,\n                    ),\n                )\n                if valid_data_re.match(path)\n            ]\n\n            # Now all temporary files\n            files.extend(\n                list(\n                    filter(\n                        os.path.isfile,\n                        glob.glob(\n                            os.path.join(temp_dir, \"*\"), recursive=False\n                        ),\n                    )\n                )\n            )\n\n            # Track if we should do a directory sweep later on\n            dir_sweep = True\n\n            # Scan our files\n            for file in files:\n                try:\n                    mtime = datetime.fromtimestamp(os.path.getmtime(file))\n\n                except FileNotFoundError:\n                    # no worries; we were removing it anyway\n                    continue\n\n                except OSError as e:\n                    # Permission error of some kind or disk problem...\n                    # There is nothing we can do at this point\n                    logger.error(\n                        \"Disk Prune (ns=%s, clean=%s) detetcted inaccessible \"\n                        \"file: %s\",\n                        namespace,\n                        \"yes\" if action else \"no\",\n                        file,\n                    )\n                    logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n                    # No longer worth doing a directory sweep\n                    dir_sweep = False\n                    continue\n\n                if expires < mtime:\n                    continue\n\n                #\n                # Handle Removing\n                #\n                record = {\n                    \"path\": file,\n                    \"removed\": False,\n                }\n\n                if action:\n                    try:\n                        os.unlink(file)\n                        # Update our record\n                        record[\"removed\"] = True\n                        logger.info(\n                            \"Disk Prune (ns=%s, clean=%s) removed persistent \"\n                            \"file: %s\",\n                            namespace,\n                            \"yes\" if action else \"no\",\n                            file,\n                        )\n\n                    except FileNotFoundError:\n                        # no longer worth doing a directory sweep\n                        dir_sweep = False\n\n                        # otherwise, no worries; we were removing the file\n                        # anyway\n\n                    except OSError as e:\n                        # Permission error of some kind or disk problem...\n                        # There is nothing we can do at this point\n                        logger.error(\n                            \"Disk Prune (ns=%s, clean=%s) failed to remove \"\n                            \"persistent file: %s\",\n                            namespace,\n                            \"yes\" if action else \"no\",\n                            file,\n                        )\n\n                        logger.debug(\n                            \"Persistent Storage Exception: %s\", str(e)\n                        )\n\n                        # No longer worth doing a directory sweep\n                        dir_sweep = False\n\n                # Store our record\n                map_[namespace].append(record)\n\n            # Memory tidy\n            del files\n\n            if dir_sweep:\n                # Gracefully cleanup our namespace directory. It's okay if we\n                # fail; This just means there were files in the directory.\n                for dirpath in (temp_dir, data_dir, base_dir):\n                    if action:\n                        try:\n                            os.rmdir(dirpath)\n                            logger.info(\n                                \"Disk Prune (ns=%s, clean=%s) removed \"\n                                \"persistent dir: %s\",\n                                namespace,\n                                \"yes\" if action else \"no\",\n                                dirpath,\n                            )\n                        except OSError:\n                            # do nothing;\n                            pass\n        return map_\n\n    def size(\n        self,\n        exclude: bool = True,\n        lazy: bool = True,\n    ) -> int:\n        \"\"\"Returns the total size of the persistent storage in bytes.\"\"\"\n\n        if lazy and self.__cache_size is not None:\n            # Take an early exit\n            return self.__cache_size\n\n        elif self.__mode == PersistentStoreMode.MEMORY:\n            # Take an early exit\n            self.__cache_size = 0\n            return self.__cache_size\n\n        # Get a list of files (file paths) in the given directory\n        try:\n            self.__cache_size = sum(os.stat(path).st_size\n                for path in self.files(exclude=exclude, lazy=lazy))\n\n        except OSError:\n            # We can't access the directory or it does not exist\n            self.__cache_size = 0\n\n        return self.__cache_size\n\n    def __del__(self) -> None:\n        \"\"\"Deconstruction of our object.\"\"\"\n\n        if self.__mode == PersistentStoreMode.AUTO:\n            # Flush changes to disk\n            self.flush()\n\n    def __delitem__(self, key: str) -> None:\n        \"\"\"Remove a cache entry by it's key.\"\"\"\n        if self._cache is None and not self.__load_cache():\n            raise KeyError(\"Could not initialize cache\")\n\n        try:\n            if self._cache[key].persistent:\n                # Set our dirty flag in advance\n                self.__dirty = True\n\n            # Store our new cache\n            del self._cache[key]\n\n        except KeyError:\n            # Nothing to do\n            raise\n\n        if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:\n            # Flush changes to disk\n            self.flush()\n\n        return\n\n    def __contains__(self, key: str) -> bool:\n        \"\"\"Verify if our storage contains the key specified or not.\n\n        In additiont to this, if the content is expired, it is considered to be\n        not contained in the storage.\n        \"\"\"\n        if self._cache is None and not self.__load_cache():\n            return False\n\n        return key in self._cache and self._cache[key]\n\n    def __setitem__(self, key: str, value: Any) -> None:\n        \"\"\"Sets a cache value without disrupting existing settings in place.\"\"\"\n\n        if self._cache is None and not self.__load_cache():\n            raise KeyError(\"Could not initialize cache\")\n\n        if key not in self._cache and not self.set(key, value):\n            raise KeyError(\"Could not set cache\")\n\n        else:\n            # Update our value\n            self._cache[key].set(value)\n\n            if self._cache[key].persistent:\n                # Set our dirty flag in advance\n                self.__dirty = True\n\n        if self.__dirty and self.__mode == PersistentStoreMode.FLUSH:\n            # Flush changes to disk\n            self.flush()\n\n        return\n\n    def __getitem__(self, key: str) -> Any:\n        \"\"\"Returns the indexed value.\"\"\"\n\n        if self._cache is None and not self.__load_cache():\n            raise KeyError(\"Could not initialize cache\")\n\n        result = self.get(key, default=self.__not_found_ref, lazy=False)\n        if result is self.__not_found_ref:\n            raise KeyError(f\" {key} not found in cache\")\n\n        return result\n\n    def keys(self) -> builtins.set[str]:\n        \"\"\"Returns our keys.\"\"\"\n        if self._cache is None and not self.__load_cache():\n            # There are no keys to return\n            return {}.keys()\n\n        return self._cache.keys()\n\n    def delete(\n        self,\n        *args: str,\n        all: Optional[bool] = None,\n        temp: Optional[bool] = None,\n        cache: Optional[bool] = None,\n        validate: bool = True,\n    ) -> bool:\n        \"\"\"Manages our file space and tidys it up.\n\n        delete('key', 'key2') delete(all=True) delete(temp=True, cache=True)\n        \"\"\"\n\n        # Our failure flag\n        has_error = False\n\n        valid_key_re = re.compile(\n            r\"^(?P<key>.+)(\"\n            + re.escape(self.__backup_extension)\n            + r\"|\"\n            + re.escape(self.__extension)\n            + r\")$\",\n            re.I,\n        )\n\n        # Default asignments\n        if all is None:\n            all = bool(not (len(args) or temp or cache))\n        if temp is None:\n            temp = bool(all)\n        if cache is None:\n            cache = bool(all)\n\n        if cache and self._cache:\n            # Reset our object\n            self._cache.clear()\n            # Reset dirt flag\n            self.__dirty = False\n\n        for path in self.files(exclude=False):\n\n            # Some information we use to validate the actions of our clean()\n            # call. This is so we don't remove anything we shouldn't\n            base = os.path.dirname(path)\n            fname = os.path.basename(path)\n\n            # Clean printable path details\n            ppath = os.path.join(os.path.dirname(base), fname)\n\n            if base == self.__base_path and cache:\n                # We're handling a cache file (hopefully)\n                result = valid_key_re.match(fname)\n                key = (\n                    None\n                    if not result\n                    else (\n                        result[\"key\"]\n                        if self.__valid_key.match(result[\"key\"])\n                        else None\n                    )\n                )\n\n                if validate and key != self.__cache_key:\n                    # We're not dealing with a cache key\n                    logger.debug(\n                        \"Persistent File cleanup ignoring file: %s\", path\n                    )\n                    continue\n\n                #\n                # We should proceed with removing the file if we get here\n                #\n\n            elif base == self.__data_path and (args or all):\n                # We're handling a file found in our custom data path\n                result = valid_key_re.match(fname)\n                key = (\n                    None\n                    if not result\n                    else (\n                        result[\"key\"]\n                        if self.__valid_key.match(result[\"key\"])\n                        else None\n                    )\n                )\n\n                if validate and key is None:\n                    # we're set to validate and a non-valid file was found\n                    logger.debug(\n                        \"Persistent File cleanup ignoring file: %s\", path\n                    )\n                    continue\n\n                elif not all and (key is None or key not in args):\n                    # no match found\n                    logger.debug(\n                        \"Persistent File cleanup ignoring file: %s\", path\n                    )\n                    continue\n\n                #\n                # We should proceed with removing the file if we get here\n                #\n\n            elif base == self.__temp_path and temp:\n                #\n                # This directory is a temporary path and nothing in here needs\n                # to be further verified. Proceed with the removing of the file\n                #\n                pass\n\n            else:\n                # No match; move on\n                logger.debug(\"Persistent File cleanup ignoring file: %s\", path)\n                continue\n\n            try:\n                os.unlink(path)\n                logger.info(\"Removed persistent file: %s\", ppath)\n\n            except FileNotFoundError:\n                # no worries; we were removing it anyway\n                pass\n\n            except OSError as e:\n                # Permission error of some kind or disk problem...\n                # There is nothing we can do at this point\n                has_error = True\n                logger.error(\"Failed to remove persistent file: %s\", ppath)\n                logger.debug(\"Persistent Storage Exception: %s\", str(e))\n\n        # Reset our reference variables\n        self.__cache_size = None\n        self.__cache_files.clear()\n\n        return not has_error\n\n    @property\n    def cache_file(self) -> str:\n        \"\"\"Returns the full path to the namespace directory.\"\"\"\n        return os.path.join(\n            self.__base_path,\n            f\"{self.__cache_key}{self.__extension}\",\n        )\n\n    @property\n    def path(self) -> Optional[str]:\n        \"\"\"Returns the full path to the namespace directory.\"\"\"\n        return self.__base_path\n\n    @property\n    def mode(self) -> PersistentStoreMode:\n        \"\"\"Returns the Persistent Storage mode.\"\"\"\n        return self.__mode\n"
  },
  {
    "path": "apprise/plugins/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport copy\nimport os\n\nfrom ..common import (\n    NOTIFY_IMAGE_SIZES,\n    NOTIFY_TYPES,\n    NotifyImageSize,\n    NotifyType,\n)\nfrom ..locale import LazyTranslation, gettext_lazy as _\nfrom ..logger import logger\nfrom ..manager_plugins import NotificationManager\nfrom ..utils.cwe312 import cwe312_url\nfrom ..utils.parse import GET_SCHEMA_RE, parse_list\n\n# Used for testing\nfrom .base import NotifyBase\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n__all__ = [\n    \"NOTIFY_IMAGE_SIZES\",\n    \"NOTIFY_TYPES\",\n    \"NotifyBase\",\n    # Reference\n    \"NotifyImageSize\",\n    \"NotifyType\",\n    # Tokenizer\n    \"url_to_dict\",\n]\n\n\ndef _sanitize_token(tokens, default_delimiter):\n    \"\"\"This is called by the details() function and santizes the output by\n    populating expected and consistent arguments if they weren't otherwise\n    specified.\"\"\"\n\n    # Used for tracking groups\n    group_map = {}\n\n    # Iterate over our tokens\n    for key in tokens:\n\n        for element in tokens[key]:\n            # Perform translations (if detected to do so)\n            if isinstance(tokens[key][element], LazyTranslation):\n                tokens[key][element] = str(tokens[key][element])\n\n        if \"alias_of\" in tokens[key]:\n            # Do not touch this field\n            continue\n\n        elif \"name\" not in tokens[key]:\n            # Default to key\n            tokens[key][\"name\"] = key\n\n        if \"map_to\" not in tokens[key]:\n            # Default type to key\n            tokens[key][\"map_to\"] = key\n\n        # Track our map_to objects\n        if tokens[key][\"map_to\"] not in group_map:\n            group_map[tokens[key][\"map_to\"]] = set()\n        group_map[tokens[key][\"map_to\"]].add(key)\n\n        if \"type\" not in tokens[key]:\n            # Default type to string\n            tokens[key][\"type\"] = \"string\"\n\n        elif tokens[key][\"type\"].startswith(\"list\"):\n            if \"delim\" not in tokens[key]:\n                # Default list delimiter (if not otherwise specified)\n                tokens[key][\"delim\"] = default_delimiter\n\n            if key in group_map[tokens[key][\"map_to\"]]:  # pragma: no branch\n                # Remove ourselves from the list\n                group_map[tokens[key][\"map_to\"]].remove(key)\n\n            # Pointing to the set directly so we can dynamically update\n            # ourselves\n            tokens[key][\"group\"] = group_map[tokens[key][\"map_to\"]]\n\n        elif (\n            tokens[key][\"type\"].startswith(\"choice\")\n            and \"default\" not in tokens[key]\n            and \"values\" in tokens[key]\n            and len(tokens[key][\"values\"]) == 1\n        ):\n\n            # If there is only one choice; then make it the default\n            #  - support dictionaries too\n            tokens[key][\"default\"] = (\n                tokens[key][\"values\"][0]\n                if not isinstance(tokens[key][\"values\"], dict)\n                else next(iter(tokens[key][\"values\"]))\n            )\n\n        if \"values\" in tokens[key] and isinstance(tokens[key][\"values\"], dict):\n            # Convert values into a list if it was defined as a dictionary\n            tokens[key][\"values\"] = list(tokens[key][\"values\"].keys())\n\n        if \"regex\" in tokens[key]:\n            # Verify that we are a tuple; convert strings to tuples\n            if isinstance(tokens[key][\"regex\"], str):\n                # Default tuple setup\n                tokens[key][\"regex\"] = (tokens[key][\"regex\"], None)\n\n            elif not isinstance(tokens[key][\"regex\"], (list, tuple)):\n                # Invalid regex\n                del tokens[key][\"regex\"]\n\n        if \"required\" not in tokens[key]:\n            # Default required is False\n            tokens[key][\"required\"] = False\n\n        if \"private\" not in tokens[key]:\n            # Private flag defaults to False if not set\n            tokens[key][\"private\"] = False\n    return\n\n\ndef details(plugin):\n    \"\"\"Provides templates that can be used by developers to build URLs\n    dynamically.\n\n    If a list of templates is provided, then they will be used over the default\n    value.\n\n    If a list of tokens are provided, then they will over-ride any additional\n    settings built from this script and/or will be appended to them afterwards.\n    \"\"\"\n\n    # Our unique list of parsing will be based on the provided templates\n    # if none are provided we will use our own\n    templates = tuple(plugin.templates)\n\n    # The syntax is simple\n    #   {\n    #       # The token_name must tie back to an entry found in the\n    #       # templates list.\n    #       'token_name': {\n    #\n    #            # types can be 'string', 'int', 'choice', 'list, 'float'\n    #            # both choice and list may additionally have a : identify\n    #            # what the list/choice type is comprised of; the default\n    #            # is string.\n    #            'type': 'choice:string',\n    #\n    #            # values will only exist the type must be a fixed\n    #            # list of inputs (generated from type choice for example)\n    #\n    #            # If this is a choice:bool then you should ALWAYS define\n    #            # this list as a (True, False) such as ('Yes, 'No') or\n    #            # ('Enabled', 'Disabled'), etc\n    #            'values': [ 'http', 'https' ],\n    #\n    #            # Identifies if the entry specified is required or not\n    #            'required': True,\n    #\n    #            # Identifies all tokens detected to be associated with the\n    #            # list:string\n    #            # This is ony present in list:string objects and is only set\n    #            # if this element acts as an alias for several other\n    #            # kwargs/fields.\n    #            'group': [],\n    #\n    #            # Identify a default value\n    #            'default': 'http',\n    #\n    #            # Optional Verification Entries min and max are for floats\n    #            # and/or integers\n    #            'min': 4,\n    #            'max': 5,\n    #\n    #            # A list will always identify a delimiter.  If this is\n    #            # part of a path, this may be a '/', or it could be a\n    #            # comma and/or space. delimiters are always in a list\n    #            #  eg (if space and/or comma is a delimiter the entry\n    #            #      would look like: 'delim': [',' , ' ' ]\n    #            'delim': None,\n    #\n    #            # Use regex if you want to share the regular expression\n    #            # required to validate the field. The regex will never\n    #            # accommodate the prefix (if one is specified).  That is\n    #            # up to the user building the URLs to include the prefix\n    #            # on the URL when constructing it.\n    #            # The format is ('regex', 'reg options')\n    #            'regex': (r'[A-Z0-9]+', 'i'),\n    #\n    #            # A Prefix is always a string, to differentiate between\n    #            # multiple arguments, sometimes content is prefixed.\n    #            'prefix': '@',\n    #\n    #            # By default the key of this object is to be interpreted\n    #            # as the argument to the notification in question. However\n    #            # To accommodate cases where there are multiple types that\n    #            # all map to the same entry, one can find a map_to value.\n    #            'map_to': 'function_arg',\n    #\n    #            # Some arguments act as an alias_of an already defined object\n    #            # This plays a role more with configuration file generation\n    #            # since yaml files allow you to define different argumuments\n    #            # in line to simplify things.  If this directive is set, then\n    #            # it should be treated exactly the same as the object it is\n    #            # an alias of\n    #            'alias_of': 'function_arg',\n    #\n    #            # Advise developers to consider the potential sensitivity\n    #            # of this field owned by the user. This is for passwords,\n    #            # and api keys, etc...\n    #            'private': False,\n    #       },\n    #   }\n\n    # Template tokens identify the arguments required to initialize the\n    # plugin itself.  It identifies all of the tokens and provides some\n    # details on their use.  Each token defined should in some way map\n    # back to at least one URL {token} defined in the templates\n\n    # Since we nest a dictionary within a dictionary, a simple copy isn't\n    # enough. a deepcopy allows us to manipulate this object in this\n    # funtion without obstructing the original.\n    template_tokens = copy.deepcopy(plugin.template_tokens)\n\n    # Arguments and/or Options either have a default value and/or are\n    # optional to be set.\n    #\n    # Since we nest a dictionary within a dictionary, a simple copy isn't\n    # enough. a deepcopy allows us to manipulate this object in this\n    # funtion without obstructing the original.\n    template_args = copy.deepcopy(plugin.template_args)\n\n    # Our template keyword arguments ?+key=value&-key=value\n    # Basically the user provides both the key and the value. this is only\n    # possibly by identifying the key prefix required for them to be\n    # interpreted hence the +/- keys are built into apprise by default for easy\n    # reference. In these cases, entry might look like '+' being the prefix:\n    #   {\n    #      'arg_name': {\n    #          'name': 'label',\n    #          'prefix': '+',\n    #       }\n    #   }\n    #\n    # Since we nest a dictionary within a dictionary, a simple copy isn't\n    # enough. a deepcopy allows us to manipulate this object in this\n    # funtion without obstructing the original.\n    template_kwargs = copy.deepcopy(plugin.template_kwargs)\n\n    # We automatically create a schema entry\n    template_tokens[\"schema\"] = {\n        \"name\": _(\"Schema\"),\n        \"type\": \"choice:string\",\n        \"required\": True,\n        \"values\": parse_list(plugin.secure_protocol, plugin.protocol),\n    }\n\n    # Sanitize our tokens\n    _sanitize_token(template_tokens, default_delimiter=(\"/\",))\n    # Delimiter(s) are space and/or comma\n    _sanitize_token(template_args, default_delimiter=(\",\", \" \"))\n    _sanitize_token(template_kwargs, default_delimiter=(\",\", \" \"))\n\n    # Argument/Option Handling\n    for key in list(template_args.keys()):\n\n        if \"alias_of\" in template_args[key]:\n            # Check if the mapped reference is a list; if it is, then\n            # we need to store a different delimiter\n            alias_of = template_tokens.get(template_args[key][\"alias_of\"], {})\n            if (\n                alias_of.get(\"type\", \"\").startswith(\"list\")\n                and \"delim\" not in template_args[key]\n            ):\n                # Set a default delimiter of a comma and/or space if one\n                # hasn't already been specified\n                template_args[key][\"delim\"] = (\",\", \" \")\n\n        # _lookup_default looks up what the default value\n        if \"_lookup_default\" in template_args[key]:\n            template_args[key][\"default\"] = getattr(\n                plugin, template_args[key][\"_lookup_default\"]\n            )\n\n            # Tidy as we don't want to pass this along in response\n            del template_args[key][\"_lookup_default\"]\n\n        # _exists_if causes the argument to only exist IF after checking\n        # the return of an internal variable requiring a check\n        if \"_exists_if\" in template_args[key]:\n            if not getattr(plugin, template_args[key][\"_exists_if\"]):\n                # Remove entire object\n                del template_args[key]\n\n            else:\n                # We only nee to remove this key\n                del template_args[key][\"_exists_if\"]\n\n    return {\n        \"templates\": templates,\n        \"tokens\": template_tokens,\n        \"args\": template_args,\n        \"kwargs\": template_kwargs,\n    }\n\n\ndef requirements(plugin):\n    \"\"\"Provides a list of packages and its requirement details.\"\"\"\n    requirements = {\n        # Use the description to provide a human interpretable description of\n        # what is required to make the plugin work. This is only nessisary\n        # if there are package dependencies\n        \"details\": \"\",\n        # Define any required packages needed for the plugin to run.  This is\n        # an array of strings that simply look like lines in the\n        # `requirements.txt` file...\n        #\n        # A single string is perfectly acceptable:\n        # 'packages_required' = 'cryptography'\n        #\n        # Multiple entries should look like the following\n        # 'packages_required' = [\n        #   'cryptography < 3.4`,\n        # ]\n        #\n        \"packages_required\": [],\n        # Recommended packages identify packages that are not required to make\n        # your plugin work, but would improve it's use or grant it access to\n        # full functionality (that might otherwise be limited).\n        # Similar to `packages_required`, you would identify each entry in\n        # the array as you would in a `requirements.txt` file.\n        #\n        #   - Do not re-provide entries already in the `packages_required`\n        \"packages_recommended\": [],\n    }\n\n    # Populate our template differently if we don't find anything above\n    if not (\n        hasattr(plugin, \"requirements\")\n        and isinstance(plugin.requirements, dict)\n    ):\n        # We're done early\n        return requirements\n\n    # Get our required packages\n    req_packages = plugin.requirements.get(\"packages_required\")\n    if isinstance(req_packages, str):\n        # Convert to list\n        req_packages = [req_packages]\n\n    elif not isinstance(req_packages, (set, list, tuple)):\n        # Allow one to set the required packages to None (as an example)\n        req_packages = []\n\n    requirements[\"packages_required\"] = [str(p) for p in req_packages]\n\n    # Get our recommended packages\n    opt_packages = plugin.requirements.get(\"packages_recommended\")\n    if isinstance(opt_packages, str):\n        # Convert to list\n        opt_packages = [opt_packages]\n\n    elif not isinstance(opt_packages, (set, list, tuple)):\n        # Allow one to set the recommended packages to None (as an example)\n        opt_packages = []\n\n    requirements[\"packages_recommended\"] = [str(p) for p in opt_packages]\n\n    # Get our package details\n    req_details = plugin.requirements.get(\"details\")\n    if not req_details:\n        if not (req_packages or opt_packages):\n            req_details = _(\"No dependencies.\")\n\n        elif req_packages:\n            req_details = _(\"Packages are required to function.\")\n\n        else:  # opt_packages\n            req_details = _(\n                \"Packages are recommended to improve functionality.\"\n            )\n    else:\n        # Store our details if defined\n        requirements[\"details\"] = req_details\n\n    # Return our compiled package requirements\n    return requirements\n\n\ndef url_to_dict(url, secure_logging=True):\n    \"\"\"Takes an apprise URL and returns the tokens associated with it if they\n    can be acquired based on the plugins available.\n\n    None is returned if the URL could not be parsed, otherwise the tokens are\n    returned.\n\n    These tokens can be loaded into apprise through it's add() function.\n    \"\"\"\n\n    # swap hash (#) tag values with their html version\n    url_ = url.replace(\"/#\", \"/%23\")\n\n    # CWE-312 (Secure Logging) Handling\n    loggable_url = url if not secure_logging else cwe312_url(url)\n\n    # Attempt to acquire the schema at the very least to allow our plugins to\n    # determine if they can make a better interpretation of a URL geared for\n    # them.\n    schema = GET_SCHEMA_RE.match(url_)\n    if schema is None:\n        # Not a valid URL; take an early exit\n        logger.error(f\"Unsupported URL: {loggable_url}\")\n        return None\n\n    # Ensure our schema is always in lower case\n    schema = schema.group(\"schema\").lower()\n    if schema not in N_MGR:\n        # Give the user the benefit of the doubt that the user may be using\n        # one of the URLs provided to them by their notification service.\n        # Before we fail for good, just scan all the plugins that support the\n        # native_url() parse function\n        results = None\n        for plugin in N_MGR.plugins():\n            results = plugin.parse_native_url(url_)\n            if results:\n                break\n\n        if not results:\n            logger.error(f\"Unparseable URL {loggable_url}\")\n            return None\n\n        logger.trace(\n            \"URL {} unpacked as:{}{}\".format(\n                url,\n                os.linesep,\n                os.linesep.join([f'{k}=\"{v}\"' for k, v in results.items()]),\n            )\n        )\n\n    else:\n        # Parse our url details of the server object as dictionary\n        # containing all of the information parsed from our URL\n        results = N_MGR[schema].parse_url(url_)\n        if not results:\n            logger.error(\n                f\"Unparseable {N_MGR[schema].service_name} URL {loggable_url}\"\n            )\n            return None\n\n        logger.trace(\n            \"{} URL {} unpacked as:{}{}\".format(\n                N_MGR[schema].service_name,\n                url,\n                os.linesep,\n                os.linesep.join([f'{k}=\"{v}\"' for k, v in results.items()]),\n            )\n        )\n\n    # Return our results\n    return results\n"
  },
  {
    "path": "apprise/plugins/africas_talking.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you must have a Africas Talking Account setup; See here:\n#  https://account.africastalking.com/\n#  From here... acquire your APIKey\n#\n# API Details: https://developers.africastalking.com/docs/sms/sending/bulk\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_bool,\n    parse_phone_no,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n\nclass AfricasTalkingSMSMode:\n    \"\"\"Africas Talking SMS Mode.\"\"\"\n\n    # BulkSMS Mode\n    BULKSMS = \"bulksms\"\n\n    # Premium Mode\n    PREMIUM = \"premium\"\n\n    # Sandbox Mode\n    SANDBOX = \"sandbox\"\n\n\n# Define the types in a list for validation purposes\nAFRICAS_TALKING_SMS_MODES = (\n    AfricasTalkingSMSMode.BULKSMS,\n    AfricasTalkingSMSMode.PREMIUM,\n    AfricasTalkingSMSMode.SANDBOX,\n)\n\n\n# Extend HTTP Error Messages\nAFRICAS_TALKING_HTTP_ERROR_MAP = {\n    100: \"Processed\",\n    101: \"Sent\",\n    102: \"Queued\",\n    401: \"Risk Hold\",\n    402: \"Invalid Sender ID\",\n    403: \"Invalid Phone Number\",\n    404: \"Unsupported Number Type\",\n    405: \"Insufficient Balance\",\n    406: \"User In Blacklist\",\n    407: \"Could Not Route\",\n    409: \"Do Not Disturb Rejection\",\n    500: \"Internal Server Error\",\n    501: \"Gateway Error\",\n    502: \"Rejected By Gateway\",\n}\n\n\nclass NotifyAfricasTalking(NotifyBase):\n    \"\"\"A wrapper for Africas Talking Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Africas Talking\"\n\n    # The services URL\n    service_url = \"https://africastalking.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"atalk\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/africas_talking/\"\n\n    # Africas Talking API Request URLs\n    notify_url = {\n        AfricasTalkingSMSMode.BULKSMS: (\n            \"https://api.africastalking.com/version1/messaging\"\n        ),\n        AfricasTalkingSMSMode.PREMIUM: (\n            \"https://content.africastalking.com/version1/messaging\"\n        ),\n        AfricasTalkingSMSMode.SANDBOX: (\n            \"https://api.sandbox.africastalking.com/version1/messaging\"\n        ),\n    }\n\n    # The maximum allowable characters allowed in the title per message\n    title_maxlen = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 160\n\n    # The maximum amount of phone numbers that can reside within a single\n    # batch transfer\n    default_batch_size = 50\n\n    # Define object templates\n    templates = (\"{schema}://{appuser}@{apikey}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"appuser\": {\n                \"name\": _(\"App User Name\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n                \"required\": True,\n            },\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"apikey\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"from\": {\n                # Your registered short code or alphanumeric\n                \"name\": _(\"From\"),\n                \"type\": \"string\",\n                \"default\": \"AFRICASTKNG\",\n                \"map_to\": \"sender\",\n            },\n            \"mode\": {\n                \"name\": _(\"SMS Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": AFRICAS_TALKING_SMS_MODES,\n                \"default\": AFRICAS_TALKING_SMS_MODES[0],\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        appuser,\n        apikey,\n        targets=None,\n        sender=None,\n        batch=None,\n        mode=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Africas Talking Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.appuser = validate_regex(\n            appuser, *self.template_tokens[\"appuser\"][\"regex\"]\n        )\n        if not self.appuser:\n            msg = (\n                f\"The Africas Talking appuser specified ({appuser}) is\"\n                \" invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = (\n                f\"The Africas Talking apikey specified ({apikey}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prepare Sender\n        self.sender = (\n            self.template_args[\"from\"][\"default\"] if sender is None else sender\n        )\n\n        # Prepare Batch Mode Flag\n        self.batch = (\n            self.template_args[\"batch\"][\"default\"] if batch is None else batch\n        )\n\n        self.mode = (\n            self.template_args[\"mode\"][\"default\"]\n            if not isinstance(mode, str)\n            else mode.lower()\n        )\n\n        if isinstance(mode, str) and mode:\n            self.mode = next(\n                (\n                    a\n                    for a in AFRICAS_TALKING_SMS_MODES\n                    if a.startswith(mode.lower())\n                ),\n                None,\n            )\n\n            if self.mode not in AFRICAS_TALKING_SMS_MODES:\n                msg = (\n                    f\"The Africas Talking mode specified ({mode}) is invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.mode = self.template_args[\"mode\"][\"default\"]\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            # Carry forward '+' if defined, otherwise do not...\n            self.targets.append(\n                (\"+\" + result[\"full\"])\n                if target.lstrip()[0] == \"+\"\n                else result[\"full\"]\n            )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Africas Talking Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\n                \"There are no Africas Talking recipients to notify\"\n            )\n            return False\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n            \"Accept\": \"application/json\",\n            \"apiKey\": self.apikey,\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        # Create a copy of the target list\n        for index in range(0, len(self.targets), batch_size):\n            # Prepare our payload\n            payload = {\n                \"username\": self.appuser,\n                \"to\": \",\".join(self.targets[index : index + batch_size]),\n                \"from\": self.sender,\n                \"message\": body,\n            }\n\n            # Acquire our URL\n            notify_url = self.notify_url[self.mode]\n\n            self.logger.debug(\n                \"Africas Talking POST URL:\"\n                f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Africas Talking Payload: {payload!s}\")\n\n            # Printable target detail\n            p_target = (\n                self.targets[index]\n                if batch_size == 1\n                else f\"{len(self.targets[index:index + batch_size])} target(s)\"\n            )\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    notify_url,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                # Sample response\n                # {\n                #     \"SMSMessageData\": {\n                #         \"Message\": \"Sent to 1/1 Total Cost: KES 0.8000\",\n                #         \"Recipients\": [{\n                #             \"statusCode\": 101,\n                #             \"number\": \"+254711XXXYYY\",\n                #             \"status\": \"Success\",\n                #             \"cost\": \"KES 0.8000\",\n                #             \"messageId\": \"ATPid_SampleTxnId123\"\n                #         }]\n                #     }\n                # }\n\n                if r.status_code not in (100, 101, 102, requests.codes.ok):\n                    # We had a problem\n                    status_str = (\n                        NotifyAfricasTalking.http_response_code_lookup(\n                            r.status_code, AFRICAS_TALKING_HTTP_ERROR_MAP\n                        )\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Africas Talking notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            p_target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent Africas Talking notification to {p_target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Africas Talking \"\n                    f\"notification to {p_target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.appuser, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        if self.sender != self.template_args[\"from\"][\"default\"]:\n            # Set our sender if it was set\n            params[\"from\"] = self.sender\n\n        if self.mode != self.template_args[\"mode\"][\"default\"]:\n            # Set our mode\n            params[\"mode\"] = self.mode\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{appuser}@{apikey}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            appuser=NotifyAfricasTalking.quote(self.appuser, safe=\"\"),\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyAfricasTalking.quote(x, safe=\"+\") for x in self.targets]\n            ),\n            params=NotifyAfricasTalking.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The Application User ID\n        results[\"appuser\"] = NotifyAfricasTalking.unquote(results[\"user\"])\n\n        # Prepare our targets\n        results[\"targets\"] = []\n\n        # Our Application APIKey\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            # Store our apikey if specified as keyword\n            results[\"apikey\"] = NotifyAfricasTalking.unquote(\n                results[\"qsd\"][\"apikey\"]\n            )\n\n            # This means our host is actually a phone number (target)\n            results[\"targets\"].append(\n                NotifyAfricasTalking.unquote(results[\"host\"])\n            )\n\n        else:\n            # First item is our apikey\n            results[\"apikey\"] = NotifyAfricasTalking.unquote(results[\"host\"])\n\n        # Store our remaining targets found on path\n        results[\"targets\"].extend(\n            NotifyAfricasTalking.split_path(results[\"fullpath\"])\n        )\n\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"sender\"] = NotifyAfricasTalking.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyAfricasTalking.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Get our Mode\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            results[\"mode\"] = NotifyAfricasTalking.unquote(\n                results[\"qsd\"][\"mode\"]\n            )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyAfricasTalking.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/apprise_api.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\nimport logging\nimport re\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_list, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n\nclass AppriseAPIMethod:\n    \"\"\"Defines the method to post data tot he remote server.\"\"\"\n\n    JSON = \"json\"\n    FORM = \"form\"\n\n\nAPPRISE_API_METHODS = (\n    AppriseAPIMethod.FORM,\n    AppriseAPIMethod.JSON,\n)\n\n\nclass NotifyAppriseAPI(NotifyBase):\n    \"\"\"A wrapper for Apprise (Persistent) API Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Apprise API\"\n\n    # The services URL\n    service_url = \"https://github.com/caronc/apprise-api\"\n\n    # The default protocol\n    protocol = \"apprise\"\n\n    # The default secure protocol\n    secure_protocol = \"apprises\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/apprise_api/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Depending on the number of transactions/notifications taking place, this\n    # could take a while. 30 seconds should be enough to perform the task\n    socket_read_timeout = 30.0\n\n    # Disable throttle rate for Apprise API requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0.0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{token}\",\n        \"{schema}://{host}:{port}/{token}\",\n        \"{schema}://{user}@{host}/{token}\",\n        \"{schema}://{user}@{host}:{port}/{token}\",\n        \"{schema}://{user}:{password}@{host}/{token}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{token}\",\n    )\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[A-Z0-9_-]{1,128}$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"tags\": {\n                \"name\": _(\"Tags\"),\n                \"type\": \"string\",\n            },\n            \"method\": {\n                \"name\": _(\"Query Method\"),\n                \"type\": \"choice:string\",\n                \"values\": APPRISE_API_METHODS,\n                \"default\": APPRISE_API_METHODS[0],\n            },\n            \"to\": {\n                \"alias_of\": \"token\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self, token=None, tags=None, method=None, headers=None, **kwargs\n    ):\n        \"\"\"Initialize Apprise API Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Apprise API token specified ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.method = (\n            self.template_args[\"method\"][\"default\"]\n            if not isinstance(method, str)\n            else method.lower()\n        )\n\n        if self.method not in APPRISE_API_METHODS:\n            msg = f\"The method specified ({method}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Build list of tags\n        self.__tags = parse_list(tags)\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        return\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"method\": self.method,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        if self.__tags:\n            params[\"tags\"] = \",\".join(list(self.__tags))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyAppriseAPI.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyAppriseAPI.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n\n        fullpath = self.fullpath.strip(\"/\")\n        return (\n            \"{schema}://{auth}{hostname}{port}{fullpath}{token}\"\n            \"/?{params}\".format(\n                schema=self.secure_protocol if self.secure else self.protocol,\n                auth=auth,\n                # never encode hostname since we're expecting it to be a\n                # valid one\n                hostname=self.host,\n                port=(\n                    \"\"\n                    if self.port is None or self.port == default_port\n                    else f\":{self.port}\"\n                ),\n                fullpath=(\n                    \"/{}/\".format(NotifyAppriseAPI.quote(fullpath, safe=\"/\"))\n                    if fullpath\n                    else \"/\"\n                ),\n                token=self.pprint(self.token, privacy, safe=\"\"),\n                params=NotifyAppriseAPI.urlencode(params),\n            )\n        )\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Apprise API Notification.\"\"\"\n\n        # Prepare HTTP Headers\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        attachments = []\n        files = []\n        if attach and self.attachment_support:\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Apprise API attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    # Our Attachment filename\n                    filename = (\n                        attachment.name\n                        if attachment.name\n                        else f\"file{no:03}.dat\"\n                    )\n\n                    if self.method == AppriseAPIMethod.JSON:\n                        # Output must be in a DataURL format (that's what\n                        # PushSafer calls it):\n                        attachments.append({\n                            \"filename\": filename,\n                            \"base64\": attachment.base64(),\n                            \"mimetype\": attachment.mimetype,\n                        })\n\n                    else:  # AppriseAPIMethod.FORM\n                        files.append((\n                            f\"file{no:02d}\",\n                            (\n                                filename,\n                                # file handle is safely closed in `finally`;\n                                # inline open is intentional\n                                open(attachment.path, \"rb\"),  # noqa: SIM115\n                                attachment.mimetype,\n                            ),\n                        ))\n\n                except (TypeError, OSError, exception.AppriseException):\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access AppriseAPI attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending AppriseAPI attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        # prepare Apprise API Object\n        payload = {\n            # Apprise API Payload\n            \"title\": title,\n            \"body\": body,\n            \"type\": notify_type.value,\n            \"format\": self.notify_format.value,\n        }\n\n        if self.method == AppriseAPIMethod.JSON:\n            headers[\"Content-Type\"] = \"application/json\"\n\n            if attachments:\n                payload[\"attachments\"] = attachments\n\n            payload = dumps(payload)\n\n        if self.__tags:\n            payload[\"tag\"] = self.__tags\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        fullpath = self.fullpath.strip(\"/\")\n        url += \"{}\".format(\"/\" + fullpath) if fullpath else \"\"\n        url += f\"/notify/{self.token}\"\n\n        # Some entries can not be over-ridden\n        headers.update({\n            # Our response to be in JSON format always\n            \"Accept\": \"application/json\",\n            # Pass our Source UUID4 Identifier\n            \"X-Apprise-ID\": self.asset._uid,\n            # Pass our current recursion count to our upstream server\n            \"X-Apprise-Recursion-Count\": str(self.asset._recursion + 1),\n        })\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                \"Apprise API POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(\n                \"Apprise API Payload: %s\", sanitize_payload(payload))\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=payload,\n                headers=headers,\n                auth=auth,\n                files=files if files else None,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyAppriseAPI.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Apprise API notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\n                    \"Sent Apprise API notification; method=%s.\", self.method\n                )\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Apprise API \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while reading one of the \"\n                \"attached files.\"\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return False\n\n        finally:\n            for file in files:\n                # Ensure all files are closed\n                file[1][1].close()\n\n        return True\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support http://hostname/notify/token and\n                http://hostname/path/notify/token\n        \"\"\"\n\n        result = re.match(\n            r\"^http(?P<secure>s?)://(?P<hostname>[A-Z0-9._-]+)\"\n            r\"(:(?P<port>[0-9]+))?\"\n            r\"(?P<path>/[^?]+?)?/notify/(?P<token>[A-Z0-9_-]{1,32})/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyAppriseAPI.parse_url(\n                \"{schema}://{hostname}{port}{path}/{token}/{params}\".format(\n                    schema=(\n                        NotifyAppriseAPI.secure_protocol\n                        if result.group(\"secure\")\n                        else NotifyAppriseAPI.protocol\n                    ),\n                    hostname=result.group(\"hostname\"),\n                    port=(\n                        \"\"\n                        if not result.group(\"port\")\n                        else \":{}\".format(result.group(\"port\"))\n                    ),\n                    path=(\n                        \"\"\n                        if not result.group(\"path\")\n                        else result.group(\"path\")\n                    ),\n                    token=result.group(\"token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else \"?{}\".format(result.group(\"params\"))\n                    ),\n                )\n            )\n\n        return None\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Support the passing of tags in the URL\n        if \"tags\" in results[\"qsd\"] and len(results[\"qsd\"][\"tags\"]):\n            results[\"tags\"] = NotifyAppriseAPI.parse_list(\n                results[\"qsd\"][\"tags\"]\n            )\n\n        # Support the 'to' & 'token' variable so that we can support rooms\n        # this way too.\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyAppriseAPI.unquote(\n                results[\"qsd\"][\"token\"]\n            )\n\n        elif \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"token\"] = NotifyAppriseAPI.unquote(results[\"qsd\"][\"to\"])\n\n        else:\n            # Start with a list of path entries to work with\n            entries = NotifyAppriseAPI.split_path(results[\"fullpath\"])\n            if entries:\n                # use our last entry found\n                results[\"token\"] = entries[-1]\n\n                # pop our last entry off\n                entries = entries[:-1]\n\n                # re-assemble our full path\n                results[\"fullpath\"] = \"/\".join(entries)\n\n        # Set method if specified\n        if \"method\" in results[\"qsd\"] and len(results[\"qsd\"][\"method\"]):\n            results[\"method\"] = NotifyAppriseAPI.unquote(\n                results[\"qsd\"][\"method\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/aprs.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# To use this plugin, you need to be a licensed ham radio operator\n#\n# Plugin constraints:\n#\n# - message length = 67 chars max.\n# - message content = ASCII 7 bit\n# - APRS messages will be sent without msg ID, meaning that\n#   ham radio operators cannot acknowledge them\n# - Bring your own APRS-IS passcode. If you don't know what\n#   this is or how to get it, then this plugin is not for you\n# - Do NOT change the Device/ToCall ID setting UNLESS this\n#   module is used outside of Apprise. This identifier helps\n#   the ham radio community with determining the software behind\n#   a given APRS message.\n# - With great (ham radio) power comes great responsibility; do\n#   not use this plugin for spamming other ham radio operators\n\n#\n# In order to digest text input which is not in plain English,\n# users can install the optional 'unidecode' package as part\n# of their venv environment. Details: see plugin description\n#\n\n#\n# You're done at this point, you only need to know your user/pass that\n# you signed up with.\n\n#  The following URLs would be accepted by Apprise:\n#   - aprs://{user}:{password}@{callsign}\n#   - aprs://{user}:{password}@{callsign1}/{callsign2}\n\n# Optional parameters:\n#   - locale --> APRS-IS target server to connect with\n#                Default: EURO --> 'euro.aprs2.net'\n#                Details: https://www.aprs2.net/\n\n#\n# APRS message format specification:\n# http://www.aprs.org/doc/APRS101.PDF\n#\n\nimport contextlib\nfrom itertools import chain\nimport re\nimport socket\nimport sys\n\nfrom .. import __version__\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_call_sign, parse_call_sign\nfrom .base import NotifyBase\n\n# Fixed APRS-IS server locales\n# Default is 'EURO'\n# See https://www.aprs2.net/ for details\n# Select the rotating server in case you\n# don\"t care about a specific locale\nAPRS_LOCALES = {\n    \"NOAM\": \"noam.aprs2.net\",\n    \"SOAM\": \"soam.aprs2.net\",\n    \"EURO\": \"euro.aprs2.net\",\n    \"ASIA\": \"asia.aprs2.net\",\n    \"AUNZ\": \"aunz.aprs2.net\",\n    \"ROTA\": \"rotate.aprs2.net\",\n}\n\n# Identify all unsupported characters\nAPRS_BAD_CHARMAP = {\n    r\"Ä\": \"Ae\",\n    r\"Ö\": \"Oe\",\n    r\"Ü\": \"Ue\",\n    r\"ä\": \"ae\",\n    r\"ö\": \"oe\",\n    r\"ü\": \"ue\",\n    r\"ß\": \"ss\",\n}\n\n# Our compiled mapping of bad characters\nAPRS_COMPILED_MAP = re.compile(r\"(\" + \"|\".join(APRS_BAD_CHARMAP.keys()) + r\")\")\n\n\nclass NotifyAprs(NotifyBase):\n    \"\"\"A wrapper for APRS Notifications via APRS-IS.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Aprs\"\n\n    # The services URL\n    service_url = \"https://www.aprs2.net/\"\n\n    # The default secure protocol\n    secure_protocol = \"aprs\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/aprs/\"\n\n    # APRS default port, supported by all core servers\n    # Details: https://www.aprs-is.net/Connecting.aspx\n    notify_port = 10152\n\n    # The maximum length of the APRS message body\n    body_maxlen = 67\n\n    # Apprise APRS Device ID / TOCALL ID\n    # This is a FIXED value which is associated with this plugin.\n    # Its value MUST NOT be changed. If you use this APRS plugin\n    # code OUTSIDE of Apprise, please request your own TOCALL ID.\n    # Details: see https://github.com/aprsorg/aprs-deviceid\n    #\n    # Do NOT use the generic \"APRS\" TOCALL ID !!!!!\n    #\n    device_id = \"APPRIS\"\n\n    # A title can not be used for APRS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Helps to reduce the number of login-related errors where the\n    # APRS-IS server \"isn't ready yet\". If we try to receive the rx buffer\n    # without this grace perid in place, we may receive \"incomplete\" responses\n    # where the login response lacks information. In case you receive too many\n    # \"Rx: APRS-IS msg is too short - needs to have at least two lines\" error\n    # messages, you might want to increase this value to a larger time span\n    # Per previous experience, do not use values lower than 0.5 (seconds)\n    request_rate_per_sec = 0.8\n\n    # Encoding of retrieved content\n    aprs_encoding = \"latin-1\"\n\n    # Define object templates\n    templates = (\"{schema}://{user}:{password}@{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_callsign\": {\n                \"name\": _(\"Target Callsign\"),\n                \"type\": \"string\",\n                \"regex\": (\n                    r\"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$\",\n                    \"i\",\n                ),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"name\": _(\"Target Callsign\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"delay\": {\n                \"name\": _(\"Resend Delay\"),\n                \"type\": \"float\",\n                \"min\": 0.0,\n                \"max\": 5.0,\n                \"default\": 0.0,\n            },\n            \"locale\": {\n                \"name\": _(\"Locale\"),\n                \"type\": \"choice:string\",\n                \"values\": APRS_LOCALES,\n                \"default\": \"EURO\",\n            },\n        },\n    )\n\n    def __init__(self, targets=None, locale=None, delay=None, **kwargs):\n        \"\"\"Initialize APRS Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Our (future) socket sobject\n        self.sock = None\n\n        # Parse our targets\n        self.targets = []\n        \"\"\"\n        Check if the user has provided credentials\n        \"\"\"\n        if not (self.user and self.password):\n            msg = \"An APRS user/pass was not provided.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        \"\"\"\n        Check if the user tries to use a read-only access\n        to APRS-IS. We need to send content, meaning that\n        read-only access will not work\n        \"\"\"\n        if self.password == \"-1\":\n            msg = \"APRS read-only passwords are not supported.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        \"\"\"\n        Check if the password is numeric\n        \"\"\"\n        if not self.password.isnumeric():\n            msg = \"Invalid APRS-IS password\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        \"\"\"\n        Convert given user name (FROM callsign) and\n        device ID to to uppercase\n        \"\"\"\n        self.user = self.user.upper()\n        self.device_id = self.device_id.upper()\n        \"\"\"\n        Check if the user has provided a locale for the\n        APRS-IS-server and validate it, if necessary\n        \"\"\"\n        if locale and locale.upper() not in APRS_LOCALES:\n            msg = (\n                \"Unsupported APRS-IS server locale. \"\n                \"Received: {}. Valid: {}\".format(\n                    locale, \", \".join(str(x) for x in APRS_LOCALES)\n                )\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Update our delay\n        if delay is None:\n            self.delay = NotifyAprs.template_args[\"delay\"][\"default\"]\n\n        else:\n            try:\n                self.delay = float(delay)\n                if (\n                    self.delay < NotifyAprs.template_args[\"delay\"][\"min\"]\n                    or self.delay >= NotifyAprs.template_args[\"delay\"][\"max\"]\n                ):\n                    raise ValueError()\n\n            except (TypeError, ValueError):\n                msg = f\"Unsupported APRS-IS delay ({delay}) specified. \"\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n        # Bump up our request_rate\n        self.request_rate_per_sec += self.delay\n\n        # Set the transmitter group\n        self.locale = (\n            NotifyAprs.template_args[\"locale\"][\"default\"]\n            if not locale\n            else locale.upper()\n        )\n\n        # Used for URL generation afterwards only\n        self.invalid_targets = []\n\n        for target in parse_call_sign(targets):\n            # Validate targets and drop bad ones\n            # We just need to know if the call sign (including SSID, if\n            # provided) is valid and can then process the input as is\n            result = is_call_sign(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropping invalid Amateur radio call sign ({target}).\",\n                )\n                self.invalid_targets.append(target.upper())\n                continue\n\n            # Store entry\n            self.targets.append(target.upper())\n\n        return\n\n    def socket_close(self):\n        \"\"\"Closes the socket connection whereas present.\"\"\"\n        if self.sock:\n            with contextlib.suppress(Exception):\n                self.sock.close()\n            self.sock = None\n\n    def socket_open(self):\n        \"\"\"Establishes the connection to the APRS-IS socket server.\"\"\"\n        self.logger.debug(\n            \"Creating socket connection with APRS-IS\"\n            f\" {APRS_LOCALES[self.locale]}:{self.notify_port}\"\n        )\n\n        try:\n            self.sock = socket.create_connection(\n                (APRS_LOCALES[self.locale], self.notify_port),\n                self.socket_connect_timeout,\n            )\n\n        except ConnectionError as e:\n            self.logger.debug(\"Socket Exception socket_open: %s\", e)\n            self.sock = None\n            return False\n\n        except socket.gaierror as e:\n            self.logger.debug(\"Socket Exception socket_open: %s\", e)\n            self.sock = None\n            return False\n\n        except socket.timeout as e:\n            self.logger.debug(\n                \"Socket Timeout Exception socket_open: %s\", e)\n            self.sock = None\n            return False\n\n        except Exception as e:\n            self.logger.debug(\"General Exception socket_open: %s\", e)\n            self.sock = None\n            return False\n\n        # We are connected.\n        # getpeername() is not supported by every OS. Therefore,\n        # we MAY receive an exception even though we are\n        # connected successfully.\n        try:\n            # Get the physical host/port of the server\n            host, port = self.sock.getpeername()\n            # and create debug info\n            self.logger.debug(f\"Connected to {host}:{port}\")\n\n        except ValueError:\n            # Seens as if we are running on an operating\n            # system that does not support getpeername()\n            # Create a minimal log file entry\n            self.logger.debug(\"Connected to APRS-IS\")\n\n        # Return success\n        return True\n\n    def aprsis_login(self):\n        \"\"\"Generate the APRS-IS login string, send it to the server and parse\n        the response.\n\n        Returns True/False wrt whether the login was successful\n        \"\"\"\n        self.logger.debug(\"socket_login: init\")\n\n        # Check if we are connected\n        if not self.sock:\n            self.logger.warning(\"socket_login: Not connected to APRS-IS\")\n            return False\n\n        # APRS-IS login string, see https://www.aprs-is.net/Connecting.aspx\n        login_str = (\n            f\"user {self.user} pass {self.password} vers apprise\"\n            f\" {__version__}\\r\\n\"\n        )\n\n        # Send the data & abort in case of error\n        if not self.socket_send(login_str):\n            self.logger.warning(\n                \"socket_login: Login to APRS-IS unsuccessful,\"\n                \" exception occurred\"\n            )\n            self.socket_close()\n            return False\n\n        rx_buf = self.socket_receive(len(login_str) + 100)\n        # Abort the remaining process in case an error has occurred\n        if not rx_buf:\n            self.logger.warning(\n                \"socket_login: Login to APRS-IS \"\n                \"unsuccessful, exception occurred\"\n            )\n            self.socket_close()\n            return False\n\n        # APRS-IS sends at least two lines of data\n        # The data that we need is in line #2 so\n        # let's split the  content and see what we have\n        rx_lines = rx_buf.splitlines()\n        if len(rx_lines) < 2:\n            self.logger.warning(\n                \"socket_login: APRS-IS msg is too short\"\n                \" - needs to have at least two lines\"\n            )\n            self.socket_close()\n            return False\n\n        # Now split the 2nd line's content and extract\n        # both call sign and login status\n        try:\n            _, _, callsign, status, _ = rx_lines[1].split(\" \", 4)\n\n        except ValueError:\n            # ValueError is returned if there were not enough elements to\n            # populate the response\n            self.logger.warning(\n                \"socket_login: received invalid response from APRS-IS\"\n            )\n            self.socket_close()\n            return False\n\n        if callsign != self.user:\n            self.logger.warning(f\"socket_login: call signs differ: {callsign}\")\n            self.socket_close()\n            return False\n\n        if status.startswith(\"unverified\"):\n            self.logger.warning(\n                \"socket_login: invalid APRS-IS password for given call sign\"\n            )\n            self.socket_close()\n            return False\n\n        # all validations are successful; we are connected\n        return True\n\n    def socket_send(self, tx_data):\n        \"\"\"Generic \"Send data to a socket\".\"\"\"\n        self.logger.debug(\"socket_send: init\")\n\n        # Check if we are connected\n        if not self.sock:\n            self.logger.warning(\"socket_send: Not connected to APRS-IS\")\n            return False\n\n        # Encode our data if we are on Python3 or later\n        payload = (\n            tx_data.encode(\"utf-8\") if sys.version_info[0] >= 3 else tx_data\n        )\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # Try to open the socket\n        # Send the content to APRS-IS\n        try:\n            self.sock.setblocking(True)\n            self.sock.settimeout(self.socket_connect_timeout)\n            self.sock.sendall(payload)\n\n        except socket.gaierror as e:\n            self.logger.warning(f\"Socket Exception socket_send: {e!s}\")\n            self.sock = None\n            return False\n\n        except socket.timeout as e:\n            self.logger.warning(f\"Socket Timeout Exception socket_send: {e!s}\")\n            self.sock = None\n            return False\n\n        except Exception as e:\n            self.logger.warning(f\"General Exception socket_send: {e!s}\")\n            self.sock = None\n            return False\n\n        self.logger.debug(\"socket_send: successful\")\n\n        # mandatory on several APRS-IS servers\n        # helps to reduce the number of errors where\n        # the server only returns an abbreviated message\n        return True\n\n    def socket_reset(self):\n        \"\"\"Resets the socket's buffer.\"\"\"\n        self.logger.debug(\"socket_reset: init\")\n        _ = self.socket_receive(0)\n        self.logger.debug(\"socket_reset: successful\")\n        return True\n\n    def socket_receive(self, rx_len):\n        \"\"\"Generic \"Receive data from a socket\".\"\"\"\n        self.logger.debug(\"socket_receive: init\")\n\n        # Check if we are connected\n        if not self.sock:\n            self.logger.warning(\"socket_receive: not connected to APRS-IS\")\n            return False\n\n        # len is zero in case we intend to\n        # reset the socket\n        if rx_len > 0:\n            self.logger.debug(\"socket_receive: Receiving data from APRS-IS\")\n\n        # Receive content from the socket\n        try:\n            self.sock.setblocking(False)\n            self.sock.settimeout(self.socket_connect_timeout)\n            rx_buf = self.sock.recv(rx_len)\n\n        except socket.gaierror as e:\n            self.logger.warning(f\"Socket Exception socket_receive: {e!s}\")\n            self.sock = None\n            return False\n\n        except socket.timeout as e:\n            self.logger.warning(\n                f\"Socket Timeout Exception socket_receive: {e!s}\"\n            )\n            self.sock = None\n            return False\n\n        except Exception as e:\n            self.logger.warning(f\"General Exception socket_receive: {e!s}\")\n            self.sock = None\n            return False\n\n        rx_buf = (\n            rx_buf.decode(self.aprs_encoding)\n            if sys.version_info[0] >= 3\n            else rx_buf\n        )\n\n        # There will be no data in case we reset the socket\n        if rx_len > 0:\n            self.logger.debug(f\"Received content: {rx_buf}\")\n\n        self.logger.debug(\"socket_receive: successful\")\n\n        return rx_buf.rstrip()\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform APRS Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to notify; we're done\n            self.logger.warning(\n                \"There are no amateur radio call signs to notify\"\n            )\n            return False\n\n        # prepare payload\n        payload = body\n\n        # sock object is \"None\" if we were unable to establish a connection\n        # In case of errors, the error message has already been sent\n        # to the logger object\n        if not self.socket_open():\n            return False\n\n        # We have established a successful connection\n        # to the socket server. Now send the login information\n        if not self.aprsis_login():\n            return False\n\n        # Login & authorization confirmed\n        # reset what is in our buffer\n        self.socket_reset()\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        self.logger.debug(\"Starting Payload setup\")\n\n        # Prepare the outgoing message\n        # Due to APRS's contraints, we need to do\n        # a lot of filtering before we can send\n        # the actual message\n        #\n        # First remove all characters from the\n        # payload that would break APRS\n        # see https://www.aprs.org/doc/APRS101.PDF pg. 71\n        payload = re.sub(r\"[{}|~]+\", \"\", payload)\n\n        payload = APRS_COMPILED_MAP.sub(  # pragma: no branch\n            lambda x: APRS_BAD_CHARMAP[x.group()], payload\n        )\n\n        # Finally, constrain output string to 67 characters as\n        # APRS messages are limited in length\n        payload = payload[:67]\n\n        # Our outgoing message MUST end with a CRLF so\n        # let's amend our payload respectively\n        payload = payload.rstrip(\"\\r\\n\") + \"\\r\\n\"\n\n        self.logger.debug(f\"Payload setup complete: {payload}\")\n\n        # send the message to our target call sign(s)\n        for index in range(0, len(targets)):\n            # prepare the output string\n            # Format:\n            # Device ID/TOCALL - our call sign - target call sign - body\n            buffer = (\n                f\"{self.user}>{self.device_id}::{targets[index]:9}:{payload}\"\n            )\n\n            # and send the content to the socket\n            # Note that there will be no response from APRS and\n            # that all exceptions are handled within the 'send' method\n            self.logger.debug(f\"Sending APRS message: {buffer}\")\n\n            # send the content\n            if not self.socket_send(buffer):\n                has_error = True\n                break\n\n            # Finally, reset our socket buffer\n            # we DO NOT read from the socket as we\n            # would simply listen to the default APRS-IS stream\n            self.socket_reset()\n\n        self.logger.debug(\"Closing socket.\")\n        self.socket_close()\n        self.logger.info(\n            \"Sent %d/%d APRS-IS notification(s)\", index + 1, len(targets)\n        )\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {}\n\n        if self.locale != NotifyAprs.template_args[\"locale\"][\"default\"]:\n            # Store our locale if not default\n            params[\"locale\"] = self.locale\n\n        if self.delay != NotifyAprs.template_args[\"delay\"][\"default\"]:\n            # Store our locale if not default\n            params[\"delay\"] = f\"{self.delay:.2f}\"\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Setup Authentication\n        auth = \"{user}:{password}@\".format(\n            user=NotifyAprs.quote(self.user, safe=\"\"),\n            password=self.pprint(\n                self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n        )\n\n        return \"{schema}://{auth}{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            auth=auth,\n            targets=\"/\".join(\n                chain(\n                    [self.pprint(x, privacy, safe=\"\") for x in self.targets],\n                    [\n                        self.pprint(x, privacy, safe=\"\")\n                        for x in self.invalid_targets\n                    ],\n                )\n            ),\n            params=NotifyAprs.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.user, self.password, self.locale)\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    def __del__(self):\n        \"\"\"Ensure we close any lingering connections.\"\"\"\n        self.socket_close()\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # All elements are targets\n        results[\"targets\"] = [NotifyAprs.unquote(results[\"host\"])]\n\n        # All entries after the hostname are additional targets\n        results[\"targets\"].extend(NotifyAprs.split_path(results[\"fullpath\"]))\n\n        # Get Delay (if set)\n        if \"delay\" in results[\"qsd\"] and len(results[\"qsd\"][\"delay\"]):\n            results[\"delay\"] = NotifyAprs.unquote(results[\"qsd\"][\"delay\"])\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyAprs.parse_list(results[\"qsd\"][\"to\"])\n\n        # Set our APRS-IS server locale's key value and convert it to uppercase\n        if \"locale\" in results[\"qsd\"] and len(results[\"qsd\"][\"locale\"]):\n            results[\"locale\"] = NotifyAprs.unquote(\n                results[\"qsd\"][\"locale\"]\n            ).upper()\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/bark.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n#\n# API: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python\n#\nimport json\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, parse_list\nfrom .base import NotifyBase\n\n# Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds\nBARK_SOUNDS = (\n    \"alarm.caf\",\n    \"anticipate.caf\",\n    \"bell.caf\",\n    \"birdsong.caf\",\n    \"bloom.caf\",\n    \"calypso.caf\",\n    \"chime.caf\",\n    \"choo.caf\",\n    \"descent.caf\",\n    \"electronic.caf\",\n    \"fanfare.caf\",\n    \"glass.caf\",\n    \"gotosleep.caf\",\n    \"healthnotification.caf\",\n    \"horn.caf\",\n    \"ladder.caf\",\n    \"mailsent.caf\",\n    \"minuet.caf\",\n    \"multiwayinvitation.caf\",\n    \"newmail.caf\",\n    \"newsflash.caf\",\n    \"noir.caf\",\n    \"paymentsuccess.caf\",\n    \"shake.caf\",\n    \"sherwoodforest.caf\",\n    \"silence.caf\",\n    \"spell.caf\",\n    \"suspense.caf\",\n    \"telegraph.caf\",\n    \"tiptoes.caf\",\n    \"typewriters.caf\",\n    \"update.caf\",\n)\n\n\n# Supported Level Entries\nclass NotifyBarkLevel:\n    \"\"\"Defines the Bark Level options.\"\"\"\n\n    ACTIVE = \"active\"\n\n    TIME_SENSITIVE = \"timeSensitive\"\n\n    PASSIVE = \"passive\"\n\n    CRITICAL = \"critical\"\n\n\nBARK_LEVELS = (\n    NotifyBarkLevel.ACTIVE,\n    NotifyBarkLevel.TIME_SENSITIVE,\n    NotifyBarkLevel.PASSIVE,\n    NotifyBarkLevel.CRITICAL,\n)\n\n\nclass NotifyBark(NotifyBase):\n    \"\"\"A wrapper for Notify Bark Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Bark\"\n\n    # The services URL\n    service_url = \"https://github.com/Finb/Bark\"\n\n    # The default protocol\n    protocol = \"bark\"\n\n    # The default secure protocol\n    secure_protocol = \"barks\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/bark/\"\n\n    # Allows the user to specify the NotifyImageSize object; this is supported\n    # through the webhook\n    image_size = NotifyImageSize.XY_128\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{targets}\",\n        \"{schema}://{host}:{port}/{targets}\",\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"target_device\": {\n                \"name\": _(\"Target Device\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"sound\": {\n                \"name\": _(\"Sound\"),\n                \"type\": \"choice:string\",\n                \"values\": BARK_SOUNDS,\n            },\n            \"level\": {\n                \"name\": _(\"Level\"),\n                \"type\": \"choice:string\",\n                \"values\": BARK_LEVELS,\n            },\n            \"volume\": {\n                \"name\": _(\"Volume\"),\n                \"type\": \"int\",\n                \"min\": 0,\n                \"max\": 10,\n            },\n            \"click\": {\n                \"name\": _(\"Click\"),\n                \"type\": \"string\",\n            },\n            \"badge\": {\n                \"name\": _(\"Badge\"),\n                \"type\": \"int\",\n                \"min\": 0,\n            },\n            \"category\": {\n                \"name\": _(\"Category\"),\n                \"type\": \"string\",\n            },\n            \"group\": {\n                \"name\": _(\"Group\"),\n                \"type\": \"string\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"icon\": {\n                \"name\": _(\"Icon URL\"),\n                \"type\": \"string\",\n            },\n            \"call\": {\n                \"name\": _(\"Call\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        targets=None,\n        include_image=True,\n        sound=None,\n        category=None,\n        group=None,\n        level=None,\n        click=None,\n        badge=None,\n        volume=None,\n        icon=None,\n        call=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify Bark Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare our URL\n        self.notify_url = \"{}://{}{}/push\".format(\n            \"https\" if self.secure else \"http\",\n            self.host,\n            (\n                f\":{self.port}\"\n                if (self.port and isinstance(self.port, int))\n                else \"\"\n            ),\n        )\n\n        # Assign our category\n        self.category = category if isinstance(category, str) else None\n\n        # Assign our group\n        self.group = group if isinstance(group, str) else None\n\n        # Initialize device list\n        self.targets = parse_list(targets)\n\n        # Place an image inline with the message body\n        self.include_image = include_image\n\n        # A clickthrough option for notifications\n        self.click = click\n\n        # Badge\n        try:\n            # Acquire our badge count if we can:\n            #  - We accept both the integer form as well as a string\n            #    representation\n            self.badge = int(badge)\n            if self.badge < 0:\n                raise ValueError()\n\n        except TypeError:\n            # NoneType means use Default; this is an okay exception\n            self.badge = None\n\n        except ValueError:\n            self.badge = None\n            self.logger.warning(\n                \"The specified Bark badge ({}) is not valid \", badge\n            )\n\n        # Sound (easy-lookup)\n        self.sound = (\n            None\n            if not sound\n            else next(\n                (f for f in BARK_SOUNDS if f.startswith(sound.lower())), None\n            )\n        )\n        if sound and not self.sound:\n            self.logger.warning(\n                \"The specified Bark sound ({}) was not found \", sound\n            )\n\n        # Volume\n        self.volume = None\n        if volume is not None:\n            try:\n                self.volume = int(volume) if volume is not None else None\n                if self.volume is not None and not (0 <= self.volume <= 10):\n                    raise ValueError()\n\n            except (TypeError, ValueError):\n                self.logger.warning(\n                    \"The specified Bark volume ({}) is not valid. \"\n                    \"Must be between 0 and 10\",\n                    volume,\n                )\n\n        # Call\n        self.call = parse_bool(call)\n\n        # Icon URL\n        self.icon = icon if isinstance(icon, str) else None\n\n        # Level\n        self.level = (\n            None\n            if not level\n            else next((f for f in BARK_LEVELS if f[0] == level[0]), None)\n        )\n        if level and not self.level:\n            self.logger.warning(\n                \"The specified Bark level ({}) is not valid \", level\n            )\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Bark Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        if not self.targets:\n            # We have nothing to notify; we're done\n            self.logger.warning(\"There are no Bark devices to notify\")\n            return False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json; charset=utf-8\",\n        }\n\n        # Prepare our payload (sample below)\n        # {\n        #     \"body\": \"Test Bark Server\",\n        #     \"markdown\": \"# Markdown Content\",\n        #     \"device_key\": \"nysrshcqielvoxsa\",\n        #     \"title\": \"bleem\",\n        #     \"category\": \"category\",\n        #     \"sound\": \"minuet.caf\",\n        #     \"badge\": 1,\n        #     \"icon\": \"https://day.app/assets/images/avatar.jpg\",\n        #     \"group\": \"test\",\n        #     \"level\": \"active\",\n        #     \"volume\": 5,\n        #     \"call\": 1,\n        #     \"url\": \"https://mritd.com\"\n        # }\n        payload = {\n            \"title\": title if title else self.app_desc,\n        }\n\n        if self.notify_format == NotifyFormat.MARKDOWN:\n            payload[\"markdown\"] = body\n        else:\n            payload[\"body\"] = body\n\n        # Acquire our image url if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        # Use custom icon if provided, otherwise use default image\n        if self.icon:\n            payload[\"icon\"] = self.icon\n        elif image_url:\n            payload[\"icon\"] = image_url\n\n        if self.sound:\n            payload[\"sound\"] = self.sound\n\n        if self.click:\n            payload[\"url\"] = self.click\n\n        if self.badge:\n            payload[\"badge\"] = self.badge\n\n        if self.level:\n            payload[\"level\"] = self.level\n\n        if self.category:\n            payload[\"category\"] = self.category\n\n        if self.group:\n            payload[\"group\"] = self.group\n\n        if self.volume:\n            payload[\"volume\"] = self.volume\n\n        if self.call:\n            payload[\"call\"] = 1\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Create a copy of the targets\n        targets = list(self.targets)\n\n        while len(targets) > 0:\n            # Retrieve our device key\n            target = targets.pop()\n\n            payload[\"device_key\"] = target\n            self.logger.debug(\n                \"Bark POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Bark Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=json.dumps(payload),\n                    headers=headers,\n                    auth=auth,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyBark.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Bark notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Bark notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Bark \"\n                    f\"notification to {target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        if self.sound:\n            params[\"sound\"] = self.sound\n\n        if self.click:\n            params[\"click\"] = self.click\n\n        if self.badge:\n            params[\"badge\"] = str(self.badge)\n\n        if self.level:\n            params[\"level\"] = self.level\n\n        if self.volume:\n            params[\"volume\"] = str(self.volume)\n\n        if self.category:\n            params[\"category\"] = self.category\n\n        if self.group:\n            params[\"group\"] = self.group\n\n        if self.icon:\n            params[\"icon\"] = self.icon\n\n        if self.call:\n            params[\"call\"] = \"yes\"\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyBark.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyBark.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            targets=\"/\".join([NotifyBark.quote(f\"{x}\") for x in self.targets]),\n            params=NotifyBark.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Apply our targets\n        results[\"targets\"] = NotifyBark.split_path(results[\"fullpath\"])\n\n        # Category\n        if \"category\" in results[\"qsd\"] and results[\"qsd\"][\"category\"]:\n            results[\"category\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"category\"].strip()\n            )\n\n        # Group\n        if \"group\" in results[\"qsd\"] and results[\"qsd\"][\"group\"]:\n            results[\"group\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"group\"].strip()\n            )\n\n        # Badge\n        if \"badge\" in results[\"qsd\"] and results[\"qsd\"][\"badge\"]:\n            results[\"badge\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"badge\"].strip()\n            )\n\n        # Volume\n        if \"volume\" in results[\"qsd\"] and results[\"qsd\"][\"volume\"]:\n            results[\"volume\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"volume\"].strip()\n            )\n\n        # Level\n        if \"level\" in results[\"qsd\"] and results[\"qsd\"][\"level\"]:\n            results[\"level\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"level\"].strip()\n            )\n\n        # Click (URL)\n        if \"click\" in results[\"qsd\"] and results[\"qsd\"][\"click\"]:\n            results[\"click\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"click\"].strip()\n            )\n\n        # Sound\n        if \"sound\" in results[\"qsd\"] and results[\"qsd\"][\"sound\"]:\n            results[\"sound\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"sound\"].strip()\n            )\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyBark.parse_list(results[\"qsd\"][\"to\"])\n\n        # use image= for consistency with the other plugins\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # Icon URL\n        if \"icon\" in results[\"qsd\"] and results[\"qsd\"][\"icon\"]:\n            results[\"icon\"] = NotifyBark.unquote(\n                results[\"qsd\"][\"icon\"].strip()\n            )\n\n        # Call\n        results[\"call\"] = parse_bool(\n            results[\"qsd\"].get(\"call\", False)\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport asyncio\nfrom collections.abc import Generator\nfrom datetime import tzinfo\nfrom functools import partial\nimport re\nfrom typing import Any, ClassVar, Optional, TypedDict, Union\nfrom zoneinfo import ZoneInfo\n\nfrom ..apprise_attachment import AppriseAttachment\nfrom ..common import (\n    NOTIFY_FORMATS,\n    OVERFLOW_MODES,\n    NotifyFormat,\n    NotifyImageSize,\n    NotifyType,\n    OverflowMode,\n    PersistentStoreMode,\n)\nfrom ..locale import Translatable, gettext_lazy as _\nfrom ..persistent_store import PersistentStore\nfrom ..url import URLBase\nfrom ..utils.format import smart_split\nfrom ..utils.parse import parse_bool\nfrom ..utils.time import zoneinfo\n\n\nclass RequirementsSpec(TypedDict, total=False):\n    \"\"\"Defines our plugin requirements.\"\"\"\n\n    packages_required: Optional[Union[str, list[str]]]\n    packages_recommended: Optional[Union[str, list[str]]]\n    details: Optional[Translatable]\n\n\nclass NotifyBase(URLBase):\n    \"\"\"This is the base class for all notification services.\"\"\"\n\n    # An internal flag used to test the state of the plugin. If set to\n    # False, then the plugin is not used.  Plugins can disable themselves\n    # due to enviroment issues (such as missing libraries, or platform\n    # dependencies that are not present).  By default all plugins are\n    # enabled.\n    enabled = True\n\n    # The category allows for parent inheritance of this object to alter\n    # this when it's function/use is intended to behave differently. The\n    # following category types exist:\n    #\n    #  native: Is a native plugin written/stored in `apprise/plugins/Notify*`\n    #  custom: Is a custom plugin written/stored in a users plugin directory\n    #          that they loaded at execution time.\n    category = \"native\"\n\n    # Some plugins may require additional packages above what is provided\n    # already by Apprise.\n    #\n    # Use this section to relay this information to the users of the script to\n    # help guide them with what they need to know if they plan on using your\n    # plugin.   The below configuration should otherwise accommodate all normal\n    # situations and will not requrie any updating:\n    requirements: ClassVar[RequirementsSpec] = {\n        # Use the description to provide a human interpretable description of\n        # what is required to make the plugin work. This is only nessisary\n        # if there are package dependencies.  Setting this to default will\n        # cause a general response to be returned.  Only set this if you plan\n        # on over-riding the default.  Always consider language support here.\n        # So before providing a value do the following in your code base:\n        #\n        #  from apprise.AppriseLocale import gettext_lazy as _\n        #\n        # 'details': _('My detailed requirements')\n        \"details\": None,\n        # Define any required packages needed for the plugin to run.  This is\n        # an array of strings that simply look like lines residing in a\n        # `requirements.txt` file...\n        #\n        # As an example, an entry may look like:\n        # 'packages_required': [\n        #   'cryptography < 3.4`,\n        # ]\n        \"packages_required\": [],\n        # Recommended packages identify packages that are not required to make\n        # your plugin work, but would improve it's use or grant it access to\n        # full functionality (that might otherwise be limited).\n        # Similar to `packages_required`, you would identify each entry in\n        # the array as you would in a `requirements.txt` file.\n        #\n        #   - Do not re-provide entries already in the `packages_required`\n        \"packages_recommended\": [],\n    }\n\n    # The services URL\n    service_url = None\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = None\n\n    # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives\n    # us a safe play range. Override the one defined already in the URLBase\n    request_rate_per_sec = 5.5\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = None\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 32768\n\n    # Defines the maximum allowable characters in the title; set this to zero\n    # if a title can't be used. Titles that are not used but are defined are\n    # automatically placed into the body\n    title_maxlen = 250\n\n    # Set the maximum line count; if this is set to anything larger then zero\n    # the message (prior to it being sent) will be truncated to this number\n    # of lines. Setting this to zero disables this feature.\n    body_max_line_count = 0\n\n    # Persistent storage default settings\n    persistent_storage = True\n\n    # Timezone Default; by setting it to None, the timezone detected\n    # on the server is used\n    timezone = None\n\n    # Default Notify Format\n    notify_format = NotifyFormat.TEXT\n\n    # Default Overflow Mode\n    overflow_mode = OverflowMode.UPSTREAM\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference\n    storage_mode = PersistentStoreMode.MEMORY\n\n    # Default Emoji Interpretation\n    interpret_emojis = False\n\n    # Support Attachments; this defaults to being disabled.\n    # Since apprise allows you to send attachments without a body or title\n    # defined, by letting Apprise know the plugin won't support attachments\n    # up front, it can quickly pass over and ignore calls to these end points.\n\n    # You must set this to true if your application can handle attachments.\n    # You must also consider a flow change to your notification if this is set\n    # to True as well as now there will be cases where both the body and title\n    # may not be set.  There will never be a case where a body, or attachment\n    # isn't set in the same call to your notify() function.\n    attachment_support = False\n\n    # Default Title HTML Tagging\n    # When a title is specified for a notification service that doesn't accept\n    # titles, by default apprise tries to give a plesant view and convert the\n    # title so that it can be placed into the body. The default is to just\n    # use a <b> tag.  The below causes the <b>title</b> to get generated:\n    default_html_tag_id = \"b\"\n\n    # Here is where we define all of the arguments we accept on the url\n    # such as: schema://whatever/?overflow=upstream&format=text\n    # These act the same way as tokens except they are optional and/or\n    # have default values set if mandatory. This rule must be followed\n    template_args = dict(\n        URLBase.template_args,\n        **{\n            \"overflow\": {\n                \"name\": _(\"Overflow Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": OVERFLOW_MODES,\n                # Provide a default\n                \"default\": overflow_mode,\n                # look up default using the following parent class value at\n                # runtime. The variable name identified here (in this case\n                # overflow_mode) is checked and it's result is placed over-top\n                # of the 'default'. This is done because once a parent class\n                # inherits this one, the overflow_mode already set as a default\n                # 'could' be potentially over-ridden and changed to a different\n                # value.\n                \"_lookup_default\": \"overflow_mode\",\n            },\n            \"format\": {\n                \"name\": _(\"Notify Format\"),\n                \"type\": \"choice:string\",\n                \"values\": NOTIFY_FORMATS,\n                # Provide a default\n                \"default\": notify_format,\n                # look up default using the following parent class value at\n                # runtime.\n                \"_lookup_default\": \"notify_format\",\n            },\n            \"emojis\": {\n                \"name\": _(\"Interpret Emojis\"),\n                # SSL Certificate Authority Verification\n                \"type\": \"bool\",\n                # Provide a default\n                \"default\": interpret_emojis,\n                # look up default using the following parent class value at\n                # runtime.\n                \"_lookup_default\": \"interpret_emojis\",\n            },\n            \"store\": {\n                \"name\": _(\"Persistent Storage\"),\n                # Use Persistent Storage\n                \"type\": \"bool\",\n                # Provide a default\n                \"default\": persistent_storage,\n                # look up default using the following parent class value at\n                # runtime.\n                \"_lookup_default\": \"persistent_storage\",\n            },\n            \"tz\": {\n                \"name\": _(\"Timezone\"),\n                \"type\": \"string\",\n                # Provide a default\n                \"default\": timezone,\n                # look up default using the following parent class value at\n                # runtime.\n                \"_lookup_default\": \"timezone\",\n            },\n        },\n    )\n\n    #\n    # Overflow Defaults / Configuration applicable to SPLIT mode only\n    #\n\n    # Display Count  [X/X]\n    #               ^^^^^^\n    #               \\\\\\\\\\\\\n    #               6 characters (space + count)\n    # Display Count  [XX/XX]\n    #               ^^^^^^^^\n    #               \\\\\\\\\\\\\\\\\n    #               8 characters (space + count)\n    # Display Count  [XXX/XXX]\n    #               ^^^^^^^^^^\n    #               \\\\\\\\\\\\\\\\\\\\\n    #               10 characters (space + count)\n    # Display Count  [XXXX/XXXX]\n    #               ^^^^^^^^^^^^\n    #               \\\\\\\\\\\\\\\\\\\\\\\\\n    #               12 characters (space + count)\n    #\n    # Given the above + some buffer we come up with the following:\n    # If this value is exceeded, display counts automatically shut off\n    overflow_max_display_count_width = 12\n\n    # The number of characters to reserver for whitespace buffering\n    # This is detected automatically, but you can enforce a value if\n    # you desire:\n    overflow_buffer = 0\n\n    # the min accepted length of a title to allow for a counter display\n    overflow_display_count_threshold = 130\n\n    # Whether or not when over-flow occurs, if the title should be repeated\n    # each time the message is split up\n    #   - None: Detect\n    #   - True: Always display title once\n    #   - False: Display the title for each occurance\n    overflow_display_title_once = None\n\n    # If this is set to to True:\n    #   The title_maxlen should be considered as a subset of the body_maxlen\n    #    Hence: len(title) + len(body) should never be greater then body_maxlen\n    #\n    # If set to False, then there is no corrorlation between title_maxlen\n    #  restrictions and that of body_maxlen\n    overflow_amalgamate_title = False\n\n    # Identifies the timezone to use;  if this is not over-ridden, then the\n    # timezone defined in the AppriseAsset() object is used instead.  The\n    # Below is expected to be in a ZoneInfo type already.  You can have this\n    # automatically initialized by specifying ?tz= on the Apprise URLs\n    __tzinfo = None\n\n    def __init__(self, **kwargs):\n        \"\"\"Initialize some general configuration that will keep things\n        consistent when working with the notifiers that will inherit this\n        class.\"\"\"\n\n        super().__init__(**kwargs)\n\n        # Store our interpret_emoji's setting\n        # If asset emoji value is set to a default of True and the user\n        #   specifies it to be false, this is accepted and False over-rides.\n        #\n        # If asset emoji value is set to a default of None, a user may\n        #   optionally over-ride this and set it to True from the Apprise\n        #   URL. ?emojis=yes\n        #\n        # If asset emoji value is set to a default of False, then all emoji's\n        # are turned off (no user over-rides allowed)\n        #\n\n        # Our Persistent Storage object is initialized on demand\n        self.__store = None\n\n        # Take a default\n        self.interpret_emojis = self.asset.interpret_emojis\n        if \"emojis\" in kwargs:\n            # possibly over-ride default\n            self.interpret_emojis = bool(\n                self.interpret_emojis in (None, True)\n                and parse_bool(\n                    kwargs.get(\"emojis\", False),\n                    default=NotifyBase.template_args[\"emojis\"][\"default\"],\n                )\n            )\n\n        if \"format\" in kwargs:\n            value = kwargs[\"format\"]\n            try:\n                self.notify_format = (\n                    value if isinstance(value, NotifyFormat)\n                    else NotifyFormat(value.lower())\n                )\n\n            except (AttributeError, ValueError):\n                err = (\n                    f\"An invalid notification format ({value}) was \"\n                    \"specified.\")\n                self.logger.warning(err)\n                raise TypeError(err) from None\n\n        if \"tz\" in kwargs:\n            value = kwargs[\"tz\"]\n            self.__tzinfo = zoneinfo(value)\n            if not self.__tzinfo:\n                err = (\n                    f\"An invalid notification timezone ({value}) was \"\n                    \"specified.\")\n                self.logger.warning(err)\n                raise TypeError(err) from None\n\n        if \"overflow\" in kwargs:\n            value = kwargs[\"overflow\"]\n            try:\n                self.overflow_mode = (\n                    value if isinstance(value, OverflowMode)\n                    else OverflowMode(value.lower())\n                )\n\n            except (AttributeError, ValueError):\n                err = f\"An invalid overflow method ({value}) was specified.\"\n                self.logger.warning(err)\n                raise TypeError(err) from None\n\n        # Prepare our Persistent Storage switch\n        self.persistent_storage = parse_bool(\n            kwargs.get(\"store\", NotifyBase.persistent_storage)\n        )\n        if not self.persistent_storage:\n            # Enforce the disabling of cache (ortherwise defaults are use)\n            self.url_identifier = False\n            self.__cached_url_identifier = None\n\n    def image_url(\n        self,\n        notify_type: NotifyType,\n        image_size: Optional[NotifyImageSize] = None,\n        logo: bool = False,\n        extension: Optional[str] = None,\n    ) -> Optional[str]:\n        \"\"\"Returns Image URL if possible.\"\"\"\n\n        image_size = self.image_size if image_size is None else image_size\n        if not image_size:\n            return None\n\n        return self.asset.image_url(\n            notify_type=notify_type,\n            image_size=image_size,\n            logo=logo,\n            extension=extension,\n        )\n\n    def image_path(\n        self,\n        notify_type: NotifyType,\n        extension: Optional[str] = None,\n    ) -> Optional[str]:\n        \"\"\"Returns the path of the image if it can.\"\"\"\n        if not self.image_size:\n            return None\n\n        return self.asset.image_path(\n            notify_type=notify_type,\n            image_size=self.image_size,\n            extension=extension,\n        )\n\n    def image_raw(\n        self,\n        notify_type: NotifyType,\n        extension: Optional[str] = None,\n    ) -> Optional[bytes]:\n        \"\"\"Returns the raw image if it can.\"\"\"\n        if not self.image_size:\n            return None\n\n        return self.asset.image_raw(\n            notify_type=notify_type,\n            image_size=self.image_size,\n            extension=extension,\n        )\n\n    def color(\n        self,\n        notify_type: NotifyType,\n        color_type: Optional[type] = None,\n    ) -> Union[str, int, tuple[int, int, int]]:\n        \"\"\"Returns the html color (hex code) associated with the\n        notify_type.\"\"\"\n\n        return self.asset.color(\n            notify_type=notify_type,\n            color_type=color_type,\n        )\n\n    def ascii(\n        self,\n        notify_type: NotifyType,\n    ) -> str:\n        \"\"\"Returns the ascii characters associated with the notify_type.\"\"\"\n\n        return self.asset.ascii(\n            notify_type=notify_type,\n        )\n\n    def notify(self, *args: Any, **kwargs: Any) -> bool:\n        \"\"\"Performs notification.\"\"\"\n        try:\n            # Build a list of dictionaries that can be used to call send().\n            send_calls = list(self._build_send_calls(*args, **kwargs))\n\n        except TypeError:\n            # Internal error\n            return False\n\n        else:\n            # Loop through each call, one at a time. (Use a list rather than a\n            # generator to call all the partials, even in case of a failure.)\n            the_calls = [self.send(**kwargs2) for kwargs2 in send_calls]\n            return all(the_calls)\n\n    async def async_notify(self, *args: Any, **kwargs: Any) -> bool:\n        \"\"\"Performs notification for asynchronous callers.\"\"\"\n        try:\n            # Build a list of dictionaries that can be used to call send().\n            send_calls = list(self._build_send_calls(*args, **kwargs))\n\n        except TypeError:\n            # Internal error\n            return False\n\n        else:\n            loop = asyncio.get_event_loop()\n\n            # Wrap each call in a coroutine that uses the default executor.\n            # TODO: In the future, allow plugins to supply a native\n            # async_send() method.\n            async def do_send(**kwargs2):\n                send = partial(self.send, **kwargs2)\n                result = await loop.run_in_executor(None, send)\n                return result\n\n            # gather() all calls in parallel.\n            the_cors = (do_send(**kwargs2) for kwargs2 in send_calls)\n            return all(await asyncio.gather(*the_cors))\n\n    def _build_send_calls(\n        self,\n        body: Optional[str] = None,\n        title: Optional[str] = None,\n        notify_type: NotifyType = NotifyType.INFO,\n        overflow: Optional[Union[str, OverflowMode]] = None,\n        attach: Optional[Union[list[str], AppriseAttachment]] = None,\n        body_format: Optional[NotifyFormat] = None,\n        **kwargs: Any,\n    ) -> Generator[dict[str, Any], None, None]:\n        \"\"\"Get a list of dictionaries that can be used to call send() or (in\n        the future) async_send().\"\"\"\n\n        if not self.enabled:\n            # Deny notifications issued to services that are disabled\n            msg = f\"{self.service_name} is currently disabled on this system.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prepare attachments if required\n        if attach is not None and not isinstance(attach, AppriseAttachment):\n            try:\n                attach = AppriseAttachment(attach, asset=self.asset)\n\n            except TypeError:\n                # bad attachments\n                raise\n\n            # Handle situations where the body is None\n            body = body if body else \"\"\n\n        elif not (body or attach):\n            # If there is not an attachment at the very least, a body must be\n            # present\n            msg = \"No message body or attachment was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if not body and not self.attachment_support:\n            # If no body was specified, then we know that an attachment\n            # was.  This is logic checked earlier in the code.\n            #\n            # Knowing this, if the plugin itself doesn't support sending\n            # attachments, there is nothing further to do here, just move\n            # along.\n            msg = (\n                f\"{self.service_name} does not support attachments; \"\n                \" service skipped\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Handle situations where the title is None\n        title = title if title else \"\"\n\n        # Truncate flag set with attachments ensures that only 1\n        # attachment passes through. In the event there could be many\n        # services specified, we only want to do this logic once.\n        # The logic is only applicable if ther was more then 1 attachment\n        # specified\n        overflow = self.overflow_mode if overflow is None else overflow\n        if attach and len(attach) > 1 and overflow == OverflowMode.TRUNCATE:\n            # Save first attachment\n            attach_ = AppriseAttachment(attach[0], asset=self.asset)\n        else:\n            # reference same attachment\n            attach_ = attach\n\n        # Apply our overflow (if defined)\n        for chunk in self._apply_overflow(\n            body=body, title=title, overflow=overflow, body_format=body_format\n        ):\n\n            # Send notification\n            yield {\n                \"body\": chunk[\"body\"],\n                \"title\": chunk[\"title\"],\n                \"notify_type\": notify_type,\n                \"attach\": attach_,\n                \"body_format\": body_format,\n            }\n\n    def _apply_overflow(\n        self,\n        body: Optional[str],\n        title: Optional[str] = None,\n        overflow: Optional[Union[str, OverflowMode]] = None,\n        body_format: Optional[NotifyFormat] = None,\n    ) -> list[dict[str, str]]:\n        \"\"\"\n        Apply overflow behaviour (UPSTREAM, TRUNCATE, SPLIT) to title/body.\n\n        Takes the message body and title as input.  This function then\n        applies any defined overflow restrictions associated with the\n        notification service and may alter the message if/as required.\n\n        The function will always return a list object in the following\n        structure:\n            [\n                {\n                    title: 'the title goes here',\n                    body: 'the message body goes here',\n                },\n                {\n                    title: 'the title goes here',\n                    body: 'the continued message body goes here',\n                },\n            ]\n        \"\"\"\n        response: list[dict[str, str]] = []\n\n        # Tidy\n        title = \"\" if not title else title.strip()\n        body = \"\" if not body else body.rstrip()\n\n        # Default overflow mode\n        if overflow is None:\n            overflow = self.overflow_mode\n\n        # Default effective body format\n        if body_format is None:\n            body_format = self.notify_format\n\n        # If the service does not support a title, amalgamate into body\n        if self.title_maxlen <= 0 and len(title) > 0:\n            if self.notify_format == NotifyFormat.HTML:\n                body = (\n                    f\"<{self.default_html_tag_id}>{title}\"\n                    f\"</{self.default_html_tag_id}>\"\n                    f\"<br />\\r\\n{body}\"\n                )\n\n            elif (\n                self.notify_format == NotifyFormat.MARKDOWN\n                and body_format == NotifyFormat.TEXT\n            ):\n                # Content is appended to body as markdown\n                title = title.lstrip(\"\\r\\n \\t\\v\\f#-\")\n                if title:\n                    body = f\"# {title}\\r\\n{body}\"\n\n            else:\n                body = f\"{title}\\r\\n{body}\"\n\n            title = \"\"\n\n        # Enforce line count\n        if self.body_max_line_count > 0:\n            lines = re.split(r\"\\r*\\n\", body)\n            body = \"\\r\\n\".join(lines[0 : self.body_max_line_count])\n\n        # UPSTREAM mode: do not touch content\n        if overflow == OverflowMode.UPSTREAM:\n            response.append({\"body\": body, \"title\": title})\n            return response\n\n        # a value of '2' allows for the \\r\\n that is applied when amalgamating\n        overflow_buffer = (\n            max(2, self.overflow_buffer)\n            if (self.title_maxlen == 0 and len(title))\n            else self.overflow_buffer\n        )\n\n        #\n        # TRUNCATE and SPLIT require sizing logic\n        #\n\n        # Handle situations where body and title are amalgamated\n        title_maxlen = (\n            self.title_maxlen\n            if not self.overflow_amalgamate_title\n            else min(\n                len(title) + self.overflow_max_display_count_width,\n                self.title_maxlen,\n                self.body_maxlen,\n            )\n        )\n\n        if len(title) > title_maxlen:\n            # Truncate our title\n            title = title[:title_maxlen].rstrip()\n\n        # Compute body_maxlen as per legacy logic\n        if (\n            self.overflow_amalgamate_title\n            and (self.body_maxlen - overflow_buffer) >= title_maxlen\n        ):\n            # status quo\n            body_maxlen = (\n                self.body_maxlen\n                if not title\n                else (self.body_maxlen - title_maxlen)\n            ) - overflow_buffer\n        else:\n            # If the body fits, we're done\n            body_maxlen = (\n                self.body_maxlen\n                if not self.overflow_amalgamate_title\n                else (self.body_maxlen - overflow_buffer)\n            )\n\n        # If the body fits, we are done\n        if body_maxlen > 0 and len(body) <= body_maxlen:\n            response.append({\"body\": body, \"title\": title})\n            return response\n\n        # TRUNCATE mode: hard truncation (no smart-splitting)\n        if overflow == OverflowMode.TRUNCATE:\n            response.append({\n                \"body\": body[:body_maxlen].lstrip(\"\\r\\n\\x0b\\x0c\").rstrip(),\n                \"title\": title,\n            })\n            return response\n\n        #\n        # SPLIT mode\n        #\n\n        # Detect if we only display our title once or not (legacy logic)\n        if self.overflow_display_title_once is None:\n            # Detect if we only display our title once or not:\n            overflow_display_title_once = bool(\n                self.overflow_amalgamate_title\n                and body_maxlen < self.overflow_display_count_threshold\n            )\n        else:\n            overflow_display_title_once = self.overflow_display_title_once\n\n        # SPLIT mode with repeated title (with/without counter)\n        if not overflow_display_title_once and not (\n            # edge case: amalgamated title but no body space\n            self.overflow_amalgamate_title\n            and body_maxlen <= 0\n        ):\n            # Decide whether to show a counter (legacy condition)\n            show_counter = (\n                title\n                and len(body) > body_maxlen\n                and (\n                    (\n                        self.overflow_amalgamate_title\n                        and body_maxlen >=\n                        self.overflow_display_count_threshold\n                    )\n                    or (\n                        not self.overflow_amalgamate_title\n                        and title_maxlen >\n                        self.overflow_display_count_threshold\n                    )\n                )\n                and (\n                    title_maxlen >\n                    (self.overflow_max_display_count_width + overflow_buffer)\n                    and self.title_maxlen >=\n                    self.overflow_display_count_threshold\n                )\n            )\n\n            effective_body_maxlen = body_maxlen\n            if show_counter:\n                # introduce padding for the counter\n                effective_body_maxlen -= overflow_buffer\n\n            # Use smart splitting instead of naive slicing\n            chunks = smart_split(\n                body,\n                effective_body_maxlen,\n                body_format,\n            )\n            count = len(chunks)\n\n            template = \"\"\n            if show_counter:\n                digits = len(str(count))\n                overflow_display_count_width = 4 + (digits * 2)\n\n                if (\n                    overflow_display_count_width\n                    <= self.overflow_max_display_count_width\n                ):\n                    # Truncate title further if needed to make room for counter\n                    if (\n                        len(title)\n                        > title_maxlen - overflow_display_count_width\n                    ):\n                        title = title[\n                            : title_maxlen - overflow_display_count_width\n                        ]\n                    template = f\" [{{:0{digits}d}}/{{:0{digits}d}}]\"\n                else:\n                    # Too many messages; fall back to repeated title without\n                    # counter displayed\n                    show_counter = False\n\n            response = []\n            for idx, chunk_body in enumerate(chunks, start=1):\n                suffix = template.format(idx, count) if show_counter else \"\"\n                response.append({\n                    \"body\": chunk_body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip(),\n                    \"title\": f\"{title}{suffix}\",\n                })\n\n        else:\n            #\n            # SPLIT mode, display title once and move on\n            # (this covers both overflow_display_title_once=True\n            #  and the edge case body_maxlen <= 0 with amalgamated title)\n            #\n            response = []\n            consumed = 0\n            remainder = body\n\n            if body_maxlen > 0 and body:\n                # First chunk uses body_maxlen (which already accounts for\n                # the title)\n                first_chunks = smart_split(\n                    body,\n                    body_maxlen,\n                    body_format,\n                )\n                first_body = first_chunks[0] if first_chunks else \"\"\n                consumed = len(first_body)\n                remainder = body[consumed:]\n\n                response.append({\n                    \"body\": first_body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip(),\n                    \"title\": title,\n                })\n\n            else:\n                # body_maxlen <= 0 or no body; send title only, still honouring\n                # body\n                response.append({\n                    \"body\": \"\",\n                    \"title\": title,\n                })\n                # remainder stays as full body; will be split below\n\n            # Remaining chunks: no title, use the full body_maxlen of the\n            # service\n            if remainder:\n                more_chunks = smart_split(\n                    remainder,\n                    self.body_maxlen,\n                    body_format,\n                )\n                for chunk_body in more_chunks:\n                    response.append({\n                        \"body\": chunk_body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip(),\n                        \"title\": \"\",\n                    })\n\n        return response\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Should preform the actual notification itself.\"\"\"\n        raise NotImplementedError(\n            \"send() is not implimented by the child class.\"\n        )\n\n    def url_parameters(\n        self,\n        *args: Any,\n        **kwargs: Any,\n    ) -> dict[str, Any]:\n        \"\"\"Provides a default set of parameters to work with.\n\n        This can greatly simplify URL construction in the acommpanied url()\n        function in all defined plugin services.\n        \"\"\"\n\n        params = {\n            \"format\": self.notify_format.value,\n            \"overflow\": self.overflow_mode.value,\n        }\n\n        # Timezone Information (if ZoneInfo)\n        if self.__tzinfo and isinstance(self.__tzinfo, ZoneInfo):\n            params[\"tz\"] = self.__tzinfo.key\n\n        # Persistent Storage Setting\n        if self.persistent_storage != NotifyBase.persistent_storage:\n            params[\"store\"] = \"yes\" if self.persistent_storage else \"no\"\n\n        params.update(super().url_parameters(*args, **kwargs))\n\n        # return default parameters\n        return params\n\n    @staticmethod\n    def parse_url(\n        url: str,\n        verify_host: bool = True,\n        plus_to_space: bool = False,\n    ) -> Optional[dict[str, Any]]:\n        \"\"\"Parses the URL and returns it broken apart into a dictionary.\n\n        This is very specific and customized for Apprise.\n\n\n        Args:\n            url (str): The URL you want to fully parse.\n            verify_host (:obj:`bool`, optional): a flag kept with the parsed\n                 URL which some child classes will later use to verify SSL\n                 keys (if SSL transactions take place).  Unless under very\n                 specific circumstances, it is strongly recomended that\n                 you leave this default value set to True.\n\n        Returns:\n            A dictionary is returned containing the URL fully parsed if\n            successful, otherwise None is returned.\n        \"\"\"\n        results = URLBase.parse_url(\n            url, verify_host=verify_host, plus_to_space=plus_to_space\n        )\n\n        if not results:\n            # We're done; we failed to parse our url\n            return results\n\n        # Allow overriding the default format\n        if \"format\" in results[\"qsd\"]:\n            results[\"format\"] = results[\"qsd\"].get(\"format\", \"\").lower()\n            if results[\"format\"] not in NOTIFY_FORMATS:\n                URLBase.logger.warning(\n                    \"Unsupported format specified \"\n                    f\"{results['qsd']['format']!r}\"\n                )\n                del results[\"format\"]\n\n        # Allow overriding the default overflow\n        if \"overflow\" in results[\"qsd\"]:\n            results[\"overflow\"] = results[\"qsd\"].get(\"overflow\", \"\").lower()\n            if results[\"overflow\"] not in OVERFLOW_MODES:\n                URLBase.logger.warning(\n                    \"Unsupported overflow mode specified \"\n                    f\"{results['qsd']['overflow']!r}\"\n                )\n                del results[\"overflow\"]\n\n        # Allow emoji's override\n        if \"emojis\" in results[\"qsd\"]:\n            results[\"emojis\"] = parse_bool(results[\"qsd\"].get(\"emojis\"))\n            # Store our persistent storage boolean\n\n        # Allow overriding the default timezone\n        if \"tz\" in results[\"qsd\"]:\n            results[\"tz\"] = results[\"qsd\"].get(\"tz\", \"\")\n\n        if \"store\" in results[\"qsd\"]:\n            results[\"store\"] = results[\"qsd\"][\"store\"]\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url: str) -> Optional[dict[str, Any]]:\n        \"\"\"This is a base class that can be optionally over-ridden by child\n        classes who can build their Apprise URL based on the one provided by\n        the notification service they choose to use.\n\n        The intent of this is to make Apprise a little more userfriendly to\n        people who aren't familiar with constructing URLs and wish to use the\n        ones that were just provied by their notification serivice that they're\n        using.\n\n        This function will return None if the passed in URL can't be matched as\n        belonging to the notification service. Otherwise this function should\n        return the same set of results that parse_url() does.\n        \"\"\"\n        return None\n\n    @property\n    def store(self):\n        \"\"\"Returns a pointer to our persistent store for use.\n\n        The best use cases are:\n         self.store.get('key')\n         self.store.set('key', 'value')\n         self.store.delete('key1', 'key2', ...)\n\n        You can also access the keys this way:\n         self.store['key']\n\n        And clear them:\n         del self.store['key']\n        \"\"\"\n        if self.__store is None:\n            # Initialize our persistent store for use\n            self.__store = PersistentStore(\n                namespace=self.url_id(),\n                path=self.asset.storage_path,\n                mode=self.asset.storage_mode,\n            )\n\n        return self.__store\n\n    @property\n    def tzinfo(self) -> tzinfo:\n        \"\"\"Returns our tzinfo file associated with this plugin if set\n        otherwise the default timezone is returned.\n        \"\"\"\n        return self.__tzinfo if self.__tzinfo else self.asset.tzinfo\n"
  },
  {
    "path": "apprise/plugins/bluesky.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# 1. Create a BlueSky account\n# 2. Access Settings -> Privacy and Security\n# 3. Generate an App Password.  Optionally grant yourself access to Direct\n#    Messages if you want to be able to send them\n# 4. Assemble your Apprise URL like:\n#       bluesky://handle@you-token-here\n#\nfrom datetime import datetime, timedelta, timezone\nimport json\nimport re\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom .base import NotifyBase\n\n# For parsing handles\nHANDLE_HOST_PARSE_RE = re.compile(r\"(?P<handle>[^.]+)\\.+(?P<host>.+)$\")\n\nIS_USER = re.compile(r\"^\\s*@?(?P<user>[A-Z0-9_]+)(\\.+(?P<host>.+))?$\", re.I)\n\n\nclass NotifyBlueSky(NotifyBase):\n    \"\"\"A wrapper for BlueSky Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"BlueSky\"\n\n    # The services URL\n    service_url = \"https://bluesky.us/\"\n\n    # Protocol\n    secure_protocol = (\"bsky\", \"bluesky\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/bluesky/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # XRPC Suffix URLs; Structured as:\n    #  https://host/{suffix}\n\n    # Taken right from google.auth.helpers:\n    clock_skew = timedelta(seconds=10)\n\n    # 1 hour in seconds (the lifetime of our token)\n    access_token_lifetime_sec = timedelta(seconds=3600)\n\n    # Detect your Decentralized Identitifer (DID), then you can get your Auth\n    # Token.\n    xrpc_suffix_did = \"/xrpc/com.atproto.identity.resolveHandle\"\n    xrpc_suffix_session = \"/xrpc/com.atproto.server.createSession\"\n    xrpc_suffix_record = \"/xrpc/com.atproto.repo.createRecord\"\n    xrpc_suffix_blob = \"/xrpc/com.atproto.repo.uploadBlob\"\n    plc_directory = \"https://plc.directory/{did}\"\n\n    # BlueSky is kind enough to return how many more requests we're allowed to\n    # continue to make within it's header response as:\n    # RateLimit-Reset: The epoc time (in seconds) we can expect our\n    #                   rate-limit to be reset.\n    # RateLimit-Remaining: an integer identifying how many requests we're\n    #                      still allow to make.\n    request_rate_per_sec = 0\n\n    # For Tracking Purposes\n    ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)\n\n    # Remaining messages\n    ratelimit_remaining = 1\n\n    # The default BlueSky host to use if one isn't specified\n    bluesky_default_host = \"bsky.social\"\n\n    # Our message body size\n    body_maxlen = 280\n\n    # BlueSky does not support a title\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{user}@{password}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n        },\n    )\n\n    def __init__(self, **kwargs):\n        \"\"\"Initialize BlueSky Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Our access token\n        self.__access_token = self.store.get(\"access_token\")\n        self.__refresh_token = None\n        self.__access_token_expiry = datetime.now(timezone.utc)\n        self.__endpoint = self.store.get(\"endpoint\")\n\n        if not self.user:\n            msg = \"A BlueSky UserID/Handle must be specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Set our default host\n        self.host = self.bluesky_default_host\n        self.__endpoint = (\n            f\"https://{self.host}\" if not self.host else self.__endpoint\n        )\n\n        # Identify our Handle (if define)\n        results = HANDLE_HOST_PARSE_RE.match(self.user)\n        if results:\n            self.user = results.group(\"handle\").strip()\n            self.host = results.group(\"host\").strip()\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform BlueSky Notification.\"\"\"\n\n        if not self.__access_token and not self.login():\n            # We failed to authenticate - we're done\n            return False\n\n        # Track our returning blob IDs as they're stored on the BlueSky server\n        blobs = []\n\n        if attach and self.attachment_support:\n            url = f\"{self.__endpoint}{self.xrpc_suffix_blob}\"\n            # We need to upload our payload first so that we can source it\n            # in remaining messages\n            for no, attachment in enumerate(attach, start=1):\n\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                if not re.match(r\"^image/.*\", attachment.mimetype, re.I):\n                    # Only support images at this time\n                    self.logger.warning(\n                        \"Ignoring unsupported BlueSky attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    continue\n\n                self.logger.debug(\n                    \"Preparing BlueSky attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n                # Upload our image and get our blob associated with it\n                postokay, response = self._fetch(\n                    url,\n                    payload=attachment,\n                )\n\n                if not postokay:\n                    # We can't post our attachment\n                    return False\n\n                # Prepare our filename\n                filename = (\n                    attachment.name if attachment.name else f\"file{no:03}.dat\"\n                )\n\n                if not (isinstance(response, dict) and response.get(\"blob\")):\n                    self.logger.debug(\n                        \"Could not attach the file to BlueSky: %s (mime=%s)\",\n                        filename,\n                        attachment.mimetype,\n                    )\n                    continue\n\n                blobs.append((response.get(\"blob\"), filename))\n\n        # Prepare our URL\n        did, endpoint = self.get_identifier()\n        url = f\"{endpoint}{self.xrpc_suffix_record}\"\n\n        # prepare our batch of payloads to create\n        payloads = []\n\n        payload = {\n            \"collection\": \"app.bsky.feed.post\",\n            \"repo\": did,\n            \"record\": {\n                \"text\": body,\n                # 'YYYY-mm-ddTHH:MM:SSZ'\n                \"createdAt\": datetime.now(tz=timezone.utc).strftime(\"%FT%XZ\"),\n                \"$type\": \"app.bsky.feed.post\",\n            },\n        }\n\n        if blobs:\n            for no, blob in enumerate(blobs, start=1):\n                payload_ = payload.copy()\n                if no > 1:\n                    #\n                    # multiple instances\n                    #\n                    # 1. update createdAt time\n                    # 2. Change text to identify image no\n                    payload_[\"record\"][\"createdAt\"] = datetime.now(\n                        tz=timezone.utc\n                    ).strftime(\"%FT%XZ\")\n                    payload_[\"record\"][\"text\"] = f\"{no:02d}/{len(blobs):02d}\"\n\n                payload_[\"record\"][\"embed\"] = {\n                    \"images\": [{\n                        \"image\": blob[0],\n                        \"alt\": blob[1],\n                    }],\n                    \"$type\": \"app.bsky.embed.images\",\n                }\n                payloads.append(payload_)\n        else:\n            payloads.append(payload)\n\n        for payload in payloads:\n            # Send Login Information\n            postokay, response = self._fetch(\n                url,\n                payload=json.dumps(payload),\n            )\n            if not postokay:\n                # We failed\n                # Bad responses look like:\n                # {\n                #   'error': 'InvalidRequest',\n                #   'message': 'reason'\n                # }\n                return False\n        return True\n\n    def get_identifier(self, user=None, login=False):\n        \"\"\"Performs a Decentralized User Lookup and returns the identifier.\"\"\"\n\n        if user is None:\n            user = self.user\n\n        user = f\"{user}.{self.host}\" if \".\" not in user else f\"{user}\"\n        did_key = f\"did.{user}\"\n        endpoint_key = f\"endpoint.{user}\"\n\n        did = self.store.get(did_key)\n        endpoint = self.store.get(endpoint_key)\n        if did and endpoint:\n            # Early return\n            return did, endpoint\n\n        # Step 1: Acquire DID from bsky.app\n        url = f\"https://public.api.bsky.app{self.xrpc_suffix_did}\"\n        params = {\"handle\": user}\n\n        # Send Login Information\n        postokay, response = self._fetch(\n            url,\n            params=params,\n            method=\"GET\",\n            # We set this boolean so internal recursion doesn't take place.\n            login=login,\n        )\n\n        if not postokay or not response or \"did\" not in response:\n            # We failed\n            return (False, False)\n\n        # Store our DID\n        did = response.get(\"did\")\n\n        # Step 2: Use DID to find the PDS\n        if did.startswith(\"did:plc:\"):\n            pds_url = self.plc_directory.format(did=did)\n\n            # PDS Query\n            postokay, service_response = self._fetch(\n                pds_url,\n                method=\"GET\",\n                # We set this boolean so internal recursion doesn't take place.\n                login=login,\n            )\n            if (\n                not postokay\n                or not service_response\n                or \"service\" not in service_response\n            ):\n                # We failed\n                return (False, False)\n\n            endpoint = next(\n                (\n                    s[\"serviceEndpoint\"]\n                    for s in service_response.get(\"service\", [])\n                    if s[\"type\"] == \"AtprotoPersonalDataServer\"\n                ),\n                None,\n            )\n\n        elif did.startswith(\"did:web:\"):\n            # Convert to domain\n            domain = did[8:]\n            web_did_url = f\"https://{domain}/.well-known/did.json\"\n            postokay, service_response = self._fetch(\n                web_did_url,\n                method=\"GET\",\n                # We set this boolean so internal recursion doesn't take place.\n                login=login,\n            )\n            if (\n                not postokay\n                or not service_response\n                or \"service\" not in service_response\n            ):\n                # We failed\n                self.logger.warning(\n                    \"Could not fetch DID document for did:web identity \"\n                    f\"{did}; ensure {web_did_url} is available.\"\n                )\n                return (False, False)\n\n            endpoint = next(\n                (\n                    s[\"serviceEndpoint\"]\n                    for s in service_response.get(\"service\", [])\n                    if s[\"type\"] == \"AtprotoPersonalDataServer\"\n                ),\n                None,\n            )\n\n        else:\n            self.logger.warning(\n                f\"Unknown BlueSky DID scheme detected in {did}\"\n            )\n            return (False, False)\n\n        # Step 3: Send to correct endpoint\n        if not endpoint:\n            self.logger.warning(\"Failed to resolve BlueSky PDS endpoint\")\n            return (False, False)\n\n        self.store.set(did_key, did)\n        self.store.set(endpoint_key, endpoint)\n        return (did, endpoint)\n\n    def login(self):\n        \"\"\"A simple wrapper to authenticate with the BlueSky Server.\"\"\"\n\n        # Acquire our Decentralized Identitifer\n        did, self.__endpoint = self.get_identifier(self.user, login=True)\n        if not did:\n            return False\n\n        url = f\"{self.__endpoint}{self.xrpc_suffix_session}\"\n\n        payload = {\n            \"identifier\": did,\n            \"password\": self.password,\n        }\n\n        # Send Login Information\n        postokay, response = self._fetch(\n            url,\n            payload=json.dumps(payload),\n            # We set this boolean so internal recursion doesn't take place.\n            login=True,\n        )\n\n        # Our response object looks like this (content has been altered for\n        # presentation purposes):\n        # {\n        #  'did': 'did:plc:ruk414jakghak402j1jqekj2',\n        #  'didDoc': {\n        #    '@context': [\n        #      'https://www.w3.org/ns/did/v1',\n        #      'https://w3id.org/security/multikey/v1',\n        #      'https://w3id.org/security/suites/secp256k1-2019/v1'\n        #    ],\n        #    'id': 'did:plc:ruk414jakghak402j1jqekj2',\n        #    'alsoKnownAs': ['at://apprise.bsky.social'],\n        #    'verificationMethod': [\n        #      {\n        #        'id': 'did:plc:ruk414jakghak402j1jqekj2#atproto',\n        #        'type': 'Multikey',\n        #        'controller': 'did:plc:ruk414jakghak402j1jqekj2',\n        #        'publicKeyMultibase' 'redacted'\n        #      }\n        #    ],\n        #  'service': [\n        #      {\n        #        'id': '#atproto_pds',\n        #        'type': 'AtprotoPersonalDataServer',\n        #        'serviceEndpoint':\n        #           'https://woodtuft.us-west.host.bsky.network'\n        #      }\n        #    ]\n        #  },\n        #  'handle': 'apprise.bsky.social',\n        #  'email': 'whoami@gmail.com',\n        #  'emailConfirmed': True,\n        #  'emailAuthFactor': False,\n        #  'accessJwt': 'redacted',\n        #  'refreshJwt': 'redacted',\n        #  'active': True,\n        # }\n\n        if not postokay or not response:\n            # We failed\n            return False\n\n        # Acquire our Token\n        self.__access_token = response.get(\"accessJwt\")\n\n        # Handle other optional arguments we can use\n        self.__access_token_expiry = (\n            self.access_token_lifetime_sec\n            + datetime.now(timezone.utc)\n            - self.clock_skew\n        )\n\n        # The Refresh Token\n        self.__refresh_token = response.get(\"refreshJwt\", self.__refresh_token)\n        self.store.set(\n            \"access_token\", self.__access_token, self.__access_token_expiry\n        )\n        self.store.set(\n            \"refresh_token\", self.__refresh_token, self.__access_token_expiry\n        )\n        self.store.set(\"endpoint\", self.__endpoint)\n\n        self.logger.info(\n            f\"Authenticated to BlueSky as {self.user}.{self.host}\"\n        )\n        return True\n\n    def _fetch(\n        self,\n        url,\n        payload=None,\n        params=None,\n        method=\"POST\",\n        content_type=None,\n        login=False,\n    ):\n        \"\"\"Wrapper to BlueSky API requests object.\"\"\"\n\n        # use what was specified, otherwise build headers dynamically\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": (\n                payload.mimetype\n                if isinstance(payload, AttachBase)\n                else (\n                    \"application/x-www-form-urlencoded; charset=utf-8\"\n                    if method == \"GET\"\n                    else \"application/json\"\n                )\n            ),\n        }\n\n        if self.__access_token:\n            # Set our token\n            headers[\"Authorization\"] = f\"Bearer {self.__access_token}\"\n\n        # Some Debug Logging\n        self.logger.debug(\n            f\"BlueSky {method} URL:\"\n            f\" {url} (cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(\n            \"BlueSky Payload: %s\",\n            (\n                str(payload)\n                if not isinstance(payload, AttachBase)\n                else \"attach: \" + payload.name\n            ),\n        )\n\n        # By default set wait to None\n        wait = None\n\n        if self.ratelimit_remaining == 0:\n            # Determine how long we should wait for or if we should wait at\n            # all. This isn't fool-proof because we can't be sure the client\n            # time (calling this script) is completely synced up with the\n            # Twitter server.  One would hope we're on NTP and our clocks are\n            # the same allowing this to role smoothly:\n\n            now = datetime.now(timezone.utc).replace(tzinfo=None)\n            if now < self.ratelimit_reset:\n                # We need to throttle for the difference in seconds\n                # We add 0.3 seconds to the end just to allow a grace\n                # period.\n                wait = (self.ratelimit_reset - now).total_seconds() + 0.3\n\n        # Always call throttle before any remote server i/o is made;\n        self.throttle(wait=wait)\n\n        # Initialize a default value for our content value\n        content = {}\n\n        # acquire our request mode\n        fn = requests.post if method == \"POST\" else requests.get\n        try:\n            r = fn(\n                url,\n                data=(\n                    payload\n                    if not isinstance(payload, AttachBase)\n                    else payload.open()\n                ),\n                params=params,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            # Get our JSON content if it's possible\n            try:\n                content = json.loads(r.content)\n\n            except (TypeError, ValueError, AttributeError):\n                # TypeError = r.content is not a String\n                # ValueError = r.content is Unparsable\n                # AttributeError = r.content is None\n                content = {}\n\n            # Rate limit handling... our header objects at this point are:\n            # 'RateLimit-Limit': '10',     # Total # of requests per hour\n            # 'RateLimit-Remaining': '9',  # Requests remaining\n            # 'RateLimit-Reset': '1741631362',  # Epoch Time\n            # 'RateLimit-Policy': '10;w=86400' # NoEntries;w=<window>\n            try:\n                # Capture rate limiting if possible\n                self.ratelimit_remaining = int(\n                    r.headers.get(\"ratelimit-remaining\")\n                )\n                self.ratelimit_reset = datetime.fromtimestamp(\n                    int(r.headers.get(\"ratelimit-reset\")), timezone.utc\n                ).replace(tzinfo=None)\n\n            except (TypeError, ValueError):\n                # This is returned if we could not retrieve this information\n                # gracefully accept this state and move on\n                pass\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyBlueSky.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send BlueSky {} to {}: {}error={}.\".format(\n                        method, url, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                return (False, content)\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"Exception received when sending BlueSky {method} to {url}: \"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            return (False, content)\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while handling {}.\".format(\n                    payload.name\n                    if isinstance(payload, AttachBase)\n                    else payload\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return (False, content)\n\n        return (True, content)\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol[0],\n            self.user,\n            self.password,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Apply our other parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        user = self.user\n        if self.host != self.bluesky_default_host:\n            user += f\".{self.host}\"\n\n        # our URL\n        return \"{schema}://{user}@{password}?{params}\".format(\n            schema=self.secure_protocol[0],\n            user=NotifyBlueSky.quote(user, safe=\"\"),\n            password=self.pprint(\n                self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            params=NotifyBlueSky.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if not results.get(\"password\") and results[\"host\"]:\n            results[\"password\"] = NotifyBlueSky.unquote(results[\"host\"])\n\n        # Do not use host field\n        results[\"host\"] = None\n        return results\n"
  },
  {
    "path": "apprise/plugins/brevo.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API Reference: https://developers.brevo.com/reference/getting-started-1\n\nfrom json import dumps\nimport logging\nfrom os.path import splitext\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyFormat, NotifyType\nfrom ..conversion import convert_between\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_list, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages (most common Brevo SMTP errors)\nBREVO_HTTP_ERROR_MAP = {\n    400: \"Bad Request - Invalid payload or missing parameters.\",\n    401: \"Unauthorized - Invalid Brevo API key.\",\n    402: \"Payment Required - Plan limitation or credit issue.\",\n    429: \"Too Many Requests - Rate limit exceeded.\",\n}\n\n# Comprehensive list of Brevo-supported extensions for Transactional Emails\n# Source: Brevo API Documentation & Transactional Attachment Guidelines\nBREVO_VALID_EXTENSIONS = (\n    # Documents & Text\n    \"xlsx\", \"xls\", \"ods\", \"docx\", \"docm\", \"doc\", \"csv\", \"pdf\", \"txt\",\n    \"rtf\", \"msg\", \"pub\", \"mobi\", \"ppt\", \"pptx\", \"eps\", \"odt\", \"ics\",\n    \"xml\", \"css\", \"html\", \"htm\", \"shtml\",\n    # Images\n    \"gif\", \"jpg\", \"jpeg\", \"png\", \"tif\", \"tiff\", \"bmp\", \"cgm\",\n    # Archives\n    \"zip\", \"tar\", \"ez\", \"pkpass\",\n    # Audio\n    \"mp3\", \"m4a\", \"m4v\", \"wma\", \"ogg\", \"flac\", \"wav\", \"aif\", \"aifc\", \"aiff\",\n    # Video\n    \"mp4\", \"mov\", \"avi\", \"mkv\", \"mpeg\", \"mpg\", \"wmv\"\n)\n\n\nclass NotifyBrevo(NotifyBase):\n    \"\"\"A wrapper for Notify Brevo Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Brevo\"\n\n    # The services URL\n    service_url = \"https://www.brevo.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"brevo\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/brevo/\"\n\n    # Default to markdown\n    notify_format = NotifyFormat.HTML\n\n    # The default Email API URL to use\n    notify_url = \"https://api.brevo.com/v3/smtp/email\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.2\n\n    # The default subject to use if one isn't specified.\n    default_empty_subject = \"<no subject>\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}:{from_email}\",\n        \"{schema}://{apikey}:{from_email}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-zA-Z0-9._-]+$\", \"i\"),\n            },\n            \"from_email\": {\n                \"name\": _(\"Source Email\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"reply\": {\n                \"name\": _(\"Reply To Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"reply_to\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        apikey,\n        from_email,\n        targets=None,\n        reply_to=None,\n        cc=None,\n        bcc=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify Brevo Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Brevo API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_email(from_email)\n        if not result:\n            msg = f\"Invalid ~From~ email specified: {from_email}\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store email address\n        self.from_email = result[\"full_email\"]\n\n        # Reply-to\n        self.reply_to = None\n        if reply_to:\n            result = is_email(reply_to)\n            if not result:\n                msg = \"An invalid Brevo Reply To ({}) was specified.\".format(\n                    f\"{reply_to}\")\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            self.reply_to = (\n                    result[\"name\"] if result[\"name\"] else False,\n                    result[\"full_email\"],\n                )\n\n        # Acquire Targets (To Emails)\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # Validate recipients (to:) and drop bad ones:\n        if targets:\n            for recipient in parse_list(targets):\n\n                result = is_email(recipient)\n                if result:\n                    self.targets.append(result[\"full_email\"])\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid email ({recipient}) specified.\",\n                )\n        else:\n            # add ourselves\n            self.targets.append(self.from_email)\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_list(cc):\n\n            result = is_email(recipient)\n            if result:\n                self.cc.add(result[\"full_email\"])\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_list(bcc):\n\n            result = is_email(recipient)\n            if result:\n                self.bcc.add(result[\"full_email\"])\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey, self.from_email)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if len(self.cc) > 0:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join(self.cc)\n\n        if len(self.bcc) > 0:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join(self.bcc)\n\n        if self.reply_to:\n            # Handle our reply to address\n            params[\"reply\"] = (\n                \"{} <{}>\".format(*self.reply_to)\n                if self.reply_to[0]\n                else self.reply_to[1]\n            )\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0] == self.from_email\n        )\n\n        return \"{schema}://{apikey}:{from_email}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            # never encode email since it plays a huge role in our hostname\n            from_email=self.from_email,\n            targets=(\n                \"\"\n                if not has_targets\n                else \"/\".join(\n                    [NotifyBrevo.quote(x, safe=\"\") for x in self.targets]\n                )\n            ),\n            params=NotifyBrevo.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return max(len(self.targets), 1)\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Brevo Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\n                \"There are no Brevo email recipients to notify\")\n            return False\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n            \"api-key\": self.apikey,\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # A Simple Email Payload Template\n        payload_ = {\n            \"sender\": {\n                \"email\": self.from_email,\n            },\n            # Placeholder, filled per target\n            \"to\": [{\"email\": None}],\n            \"subject\": title if title else self.default_empty_subject,\n        }\n        # Body selection\n        use_html = self.notify_format == NotifyFormat.HTML\n\n        if use_html:\n            # body already normalised; keep your existing logic\n            payload_[\"htmlContent\"] = body\n            payload_[\"textContent\"] = convert_between(\n                NotifyFormat.HTML, NotifyFormat.TEXT, body\n            )\n        else:\n            # Plain text requested, but Brevo still wants HTML\n            payload_[\"textContent\"] = body\n            payload_[\"htmlContent\"] = convert_between(\n                NotifyFormat.TEXT, NotifyFormat.HTML, body\n            )\n\n        if attach and self.attachment_support:\n            attachments = []\n\n            # Send our attachments\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Brevo attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                # Brevo does not track content/mime type and relies 100%\n                # entirely on the filename extension as to whether or not it\n                # will accept it or not.\n                #\n                # The below prepares a safe_name (which can't be .dat like\n                # other plugins since Brevo rejects that type). For this\n                # reason .txt is chosen intentionally for this circumstance.\n\n                # Use the attachment name if available, otherwise default to a\n                # generic name\n                raw_name = attachment.name \\\n                    if attachment.name else f\"file{no:03}.txt\"\n\n                # If the filename does NOT match a supported extension, append\n                # .txt\n                _, ext = splitext(raw_name)\n                safe_name = f\"{raw_name}.txt\" if (\n                    not ext or ext[1:].lower()\n                    not in BREVO_VALID_EXTENSIONS) else raw_name\n\n                try:\n                    attachments.append({\n                        \"content\": attachment.base64(),\n                        \"name\": safe_name,\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Brevo attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending Brevo attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n            # Append our attachments to the payload\n            payload_.update({\n                \"attachment\": attachments,\n            })\n\n        if self.reply_to:\n            payload_[\"replyTo\"] = {\"email\": self.reply_to[1]}\n\n        targets = list(self.targets)\n        while len(targets) > 0:\n            target = targets.pop(0)\n\n            # Create a copy of our template\n            payload = payload_.copy()\n\n            # the cc, bcc, to field must be unique or SendMail will fail, the\n            # below code prepares this by ensuring the target isn't in the cc\n            # list or bcc list. It also makes sure the cc list does not contain\n            # any of the bcc entries\n            cc = self.cc - self.bcc - {target}\n            bcc = self.bcc - {target}\n\n            # Set our main recipient\n            payload[\"to\"] = [{\"email\": target}]\n\n            if len(cc):\n                payload[\"cc\"] = [{\"email\": email} for email in cc]\n\n            if len(bcc):\n                payload[\"bcc\"] = [{\"email\": email} for email in bcc]\n\n            # Some Debug Logging\n            if self.logger.isEnabledFor(logging.DEBUG):\n                # Due to attachments; output can be quite heavy and io\n                # intensive.\n                # To accommodate this, we only show our debug payload\n                # information if required.\n                self.logger.debug(\n                    \"Brevo POST URL:\"\n                    f\" {self.notify_url} \"\n                    f\"(cert_verify={self.verify_certificate!r})\"\n                )\n                self.logger.debug(\n                    \"Brevo Payload: %s\", sanitize_payload(payload))\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.accepted,\n                    requests.codes.created,\n                ):\n                    # We had a problem\n                    status_str = NotifyBrevo.http_response_code_lookup(\n                        r.status_code, BREVO_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Brevo notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent Brevo notification to {target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Brevo \"\n                    f\"notification to {target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Our URL looks like this:\n        #    {schema}://{apikey}:{from_email}/{targets}\n        #\n        # which actually equates to:\n        #    {schema}://{user}:{password}@{host}/{email1}/{email2}/etc..\n        #                 ^       ^         ^\n        #                 |       |         |\n        #              apikey     -from addr-\n\n        if not results.get(\"user\"):\n            # An API Key as not properly specified\n            return None\n\n        if not results.get(\"password\"):\n            # A From Email was not correctly specified\n            return None\n\n        # Prepare our API Key\n        results[\"apikey\"] = NotifyBrevo.unquote(results[\"user\"])\n\n        # Prepare our From Email Address\n        results[\"from_email\"] = \"{}@{}\".format(\n            NotifyBrevo.unquote(results[\"password\"]),\n            NotifyBrevo.unquote(results[\"host\"]),\n        )\n\n        # Acquire our targets\n        results[\"targets\"] = NotifyBrevo.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyBrevo.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = NotifyBrevo.parse_list(results[\"qsd\"][\"cc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = NotifyBrevo.parse_list(results[\"qsd\"][\"bcc\"])\n\n        # Handle Reply To Address\n        if \"reply\" in results[\"qsd\"] and len(results[\"qsd\"][\"reply\"]):\n            results[\"reply_to\"] = NotifyBrevo.unquote(results[\"qsd\"][\"reply\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/bulksms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this service you will need a BulkSMS account\n# You will need credits (new accounts start with a few)\n#     https://www.bulksms.com/account/\n#\n# API is documented here:\n#   - https://www.bulksms.com/developer/json/v1/#tag/Message\nfrom itertools import chain\nimport json\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_bool, parse_phone_no\nfrom .base import NotifyBase\n\nIS_GROUP_RE = re.compile(\n    r\"^(@?(?P<group>[A-Z0-9_-]+))$\",\n    re.IGNORECASE,\n)\n\n\nclass BulkSMSRoutingGroup:\n    \"\"\"The different categories of routing.\"\"\"\n\n    ECONOMY = \"ECONOMY\"\n    STANDARD = \"STANDARD\"\n    PREMIUM = \"PREMIUM\"\n\n\n# Used for verification purposes\nBULKSMS_ROUTING_GROUPS = (\n    BulkSMSRoutingGroup.ECONOMY,\n    BulkSMSRoutingGroup.STANDARD,\n    BulkSMSRoutingGroup.PREMIUM,\n)\n\n\nclass BulkSMSEncoding:\n    \"\"\"The different categories of routing.\"\"\"\n\n    TEXT = \"TEXT\"\n    UNICODE = \"UNICODE\"\n    BINARY = \"BINARY\"\n\n\nclass NotifyBulkSMS(NotifyBase):\n    \"\"\"A wrapper for BulkSMS Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"BulkSMS\"\n\n    # The services URL\n    service_url = \"https://bulksms.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"bulksms\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/bulksms/\"\n\n    # BulkSMS uses the http protocol with JSON requests\n    notify_url = \"https://api.bulksms.com/v1/messages\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # The maximum amount of texts that can go out in one batch\n    default_batch_size = 4000\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{user}:{password}@{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"target_group\": {\n                \"name\": _(\"Target Group\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"regex\": (r\"^[A-Z0-9 _-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n            \"route\": {\n                \"name\": _(\"Route Group\"),\n                \"type\": \"choice:string\",\n                \"values\": BULKSMS_ROUTING_GROUPS,\n                \"default\": BulkSMSRoutingGroup.STANDARD,\n            },\n            \"unicode\": {\n                # Unicode characters\n                \"name\": _(\"Unicode Characters\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        source=None,\n        targets=None,\n        unicode=None,\n        batch=None,\n        route=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize BulkSMS Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.source = None\n        if source:\n            result = is_phone_no(source)\n            if not result:\n                msg = (\n                    \"The Account (From) Phone # specified \"\n                    f\"({source}) is invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            # Tidy source\n            self.source = \"+{}\".format(result[\"full\"])\n\n        # Setup our route\n        self.route = (\n            self.template_args[\"route\"][\"default\"]\n            if not isinstance(route, str)\n            else route.upper()\n        )\n        if self.route not in BULKSMS_ROUTING_GROUPS:\n            msg = f\"The route specified ({route}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Define whether or not we should set the unicode flag\n        self.unicode = (\n            self.template_args[\"unicode\"][\"default\"]\n            if unicode is None\n            else bool(unicode)\n        )\n\n        # Define whether or not we should operate in a batch mode\n        self.batch = (\n            self.template_args[\"batch\"][\"default\"]\n            if batch is None\n            else bool(batch)\n        )\n\n        # Parse our targets\n        self.targets = []\n        self.groups = []\n\n        for target in parse_phone_no(targets):\n            # Parse each phone number we found\n            result = is_phone_no(target)\n            if result:\n                self.targets.append(\"+{}\".format(result[\"full\"]))\n                continue\n\n            group_re = IS_GROUP_RE.match(target)\n            if group_re and not target.isdigit():\n                # If the target specified is all digits, it MUST have a @\n                # in front of it to eliminate any ambiguity\n                self.groups.append(group_re.group(\"group\"))\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid phone # and/or Group ({target}) specified.\",\n            )\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform BulkSMS Notification.\"\"\"\n\n        if not (self.password and self.user):\n            self.logger.warning(\n                \"There were no valid login credentials provided\"\n            )\n            return False\n\n        if not (self.targets or self.groups):\n            # We have nothing to notify\n            self.logger.warning(\"There are no BulkSMS targets to notify\")\n            return False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {\n            # The To gets populated in the loop below\n            \"to\": None,\n            \"body\": body,\n            \"routingGroup\": self.route,\n            \"encoding\": (\n                BulkSMSEncoding.UNICODE\n                if self.unicode\n                else BulkSMSEncoding.TEXT\n            ),\n            # Options are NONE, ALL and ERRORS\n            \"deliveryReports\": \"ERRORS\",\n        }\n\n        if self.source:\n            payload.update({\n                \"from\": self.source,\n            })\n\n        # Authentication\n        auth = (self.user, self.password)\n\n        # Prepare our targets\n        targets = (\n            list(self.targets)\n            if batch_size == 1\n            else [\n                self.targets[index : index + batch_size]\n                for index in range(0, len(self.targets), batch_size)\n            ]\n        )\n        targets += [{\"type\": \"GROUP\", \"name\": g} for g in self.groups]\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our user\n            payload[\"to\"] = target\n\n            # Printable reference\n            if isinstance(target, dict):\n                p_target = target[\"name\"]\n\n            elif isinstance(target, list):\n                p_target = f\"{len(target)} targets\"\n\n            else:\n                p_target = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"BulkSMS POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"BulkSMS Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=json.dumps(payload),\n                    headers=headers,\n                    auth=auth,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # The responsne might look like:\n                # [\n                #   {\n                #       \"id\": \"string\",\n                #       \"type\": \"SENT\",\n                #       \"from\": \"string\",\n                #       \"to\": \"string\",\n                #       \"body\": null,\n                #       \"encoding\": \"TEXT\",\n                #       \"protocolId\": 0,\n                #       \"messageClass\": 0,\n                #       \"numberOfParts\": 0,\n                #       \"creditCost\": 0,\n                #       \"submission\": {...},\n                #       \"status\": {...},\n                #       \"relatedSentMessageId\": \"string\",\n                #       \"userSuppliedId\": \"string\"\n                #   }\n                # ]\n\n                if r.status_code not in (\n                    requests.codes.created,\n                    requests.codes.ok,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    # set up our status code to use\n                    status_code = r.status_code\n\n                    self.logger.warning(\n                        \"Failed to send BulkSMS notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            p_target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent BulkSMS notification to {p_target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending BulkSMS: to %s \",\n                    p_target,\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"unicode\": \"yes\" if self.unicode else \"no\",\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"route\": self.route,\n        }\n\n        if self.source:\n            params[\"from\"] = self.source\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{user}:{password}@{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            user=self.pprint(self.user, privacy, safe=\"\"),\n            password=self.pprint(\n                self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            targets=\"/\".join(\n                chain(\n                    [\n                        NotifyBulkSMS.quote(f\"{x}\", safe=\"+\")\n                        for x in self.targets\n                    ],\n                    [\n                        NotifyBulkSMS.quote(f\"@{x}\", safe=\"@\")\n                        for x in self.groups\n                    ],\n                )\n            ),\n            params=NotifyBulkSMS.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.user if self.user else None,\n            self.password if self.password else None,\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n\n        #\n        # Factor batch into calculation\n        #\n        # Note: Groups always require a separate request (and can not be\n        # included in batch calculations)\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets + len(self.groups)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = [\n            NotifyBulkSMS.unquote(results[\"host\"]),\n            *NotifyBulkSMS.split_path(results[\"fullpath\"]),\n        ]\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyBulkSMS.unquote(results[\"qsd\"][\"from\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyBulkSMS.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Unicode Characters\n        results[\"unicode\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"unicode\", NotifyBulkSMS.template_args[\"unicode\"][\"default\"]\n            )\n        )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyBulkSMS.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        # Allow one to define a route group\n        if \"route\" in results[\"qsd\"] and len(results[\"qsd\"][\"route\"]):\n            results[\"route\"] = NotifyBulkSMS.unquote(results[\"qsd\"][\"route\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/bulkvs.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this service you will need a BulkVS account\n# You will need credits (new accounts start with a few)\n#     https://www.bulkvs.com/\n\n# API is documented here:\n#   - https://portal.bulkvs.com/api/v1.0/documentation#/\\\n#             Messaging/post_messageSend\nimport json\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_bool, parse_phone_no\nfrom .base import NotifyBase\n\n\nclass NotifyBulkVS(NotifyBase):\n    \"\"\"A wrapper for BulkVS Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"BulkVS\"\n\n    # The services URL\n    service_url = \"https://www.bulkvs.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"bulkvs\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/bulkvs/\"\n\n    # BulkVS uses the http protocol with JSON requests\n    notify_url = \"https://portal.bulkvs.com/api/v1.0/messageSend\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # The maximum amount of texts that can go out in one batch\n    default_batch_size = 4000\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}:{password}@{from_phone}/{targets}\",\n        \"{schema}://{user}:{password}@{from_phone}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n                \"required\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(self, source=None, targets=None, batch=None, **kwargs):\n        \"\"\"Initialize BulkVS Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if not (self.user and self.password):\n            msg = \"A BulkVS user/pass was not provided.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_phone_no(source)\n        if not result:\n            msg = (\n                f\"The Account (From) Phone # specified ({source}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Tidy source\n        self.source = result[\"full\"]\n\n        # Define whether or not we should operate in a batch mode\n        self.batch = (\n            self.template_args[\"batch\"][\"default\"]\n            if batch is None\n            else bool(batch)\n        )\n\n        # Parse our targets\n        self.targets = []\n\n        has_error = False\n        for target in parse_phone_no(targets):\n            # Parse each phone number we found\n            result = is_phone_no(target)\n            if result:\n                self.targets.append(result[\"full\"])\n                continue\n\n            has_error = True\n            self.logger.warning(\n                f\"Dropped invalid phone # ({target}) specified.\",\n            )\n\n        if not targets and not has_error:\n            # Default the SMS Message to ourselves\n            self.targets.append(self.source)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform BulkVS Notification.\"\"\"\n\n        if not self.targets:\n            # We have nothing to notify\n            self.logger.warning(\"There are no BulkVS targets to notify\")\n            return False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {\n            # The To gets populated in the loop below\n            \"From\": self.source,\n            \"To\": None,\n            \"Message\": body,\n        }\n\n        # Authentication\n        auth = (self.user, self.password)\n\n        # Prepare our targets\n        targets = (\n            list(self.targets)\n            if batch_size == 1\n            else [\n                self.targets[index : index + batch_size]\n                for index in range(0, len(self.targets), batch_size)\n            ]\n        )\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our user\n            payload[\"To\"] = target\n\n            # Printable reference\n            if isinstance(target, list):\n                p_target = f\"{len(target)} targets\"\n\n            else:\n                p_target = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"BulkVS POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"BulkVS Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=json.dumps(payload),\n                    headers=headers,\n                    auth=auth,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # A Response may look like:\n                # {\n                #   \"RefId\": \"5a66dee6-ff7a-40ee-8218-5805c074dc01\",\n                #   \"From\": \"13109060901\",\n                #   \"MessageType\": \"SMS|MMS\",\n                #   \"Results\": [\n                #     {\n                #       \"To\": \"13105551212\",\n                #       \"Status\": \"SUCCESS\"\n                #     },\n                #     {\n                #       \"To\": \"13105551213\",\n                #       \"Status\": \"SUCCESS\"\n                #     }\n                #   ]\n                # }\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    # set up our status code to use\n                    status_code = r.status_code\n\n                    self.logger.warning(\n                        \"Failed to send BulkVS notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            p_target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent BulkVS notification to {p_target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending BulkVS: to %s \",\n                    p_target,\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.source, self.user, self.password)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # A nice way of cleaning up the URL length a bit\n        targets = (\n            []\n            if len(self.targets) == 1 and self.targets[0] == self.source\n            else self.targets\n        )\n\n        return (\n            \"{schema}://{user}:{password}@{source}/{targets}?{params}\".format(\n                schema=self.secure_protocol,\n                source=self.source,\n                user=self.pprint(self.user, privacy, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                targets=\"/\".join(\n                    [NotifyBulkVS.quote(f\"{x}\", safe=\"+\") for x in targets]\n                ),\n                params=NotifyBulkVS.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets) if self.targets else 1\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyBulkVS.unquote(results[\"qsd\"][\"from\"])\n\n            # hostname will also be a target in this case\n            results[\"targets\"] = [\n                *NotifyBulkVS.parse_phone_no(results[\"host\"]),\n                *NotifyBulkVS.split_path(results[\"fullpath\"]),\n            ]\n\n        else:\n            # store our source\n            results[\"source\"] = NotifyBulkVS.unquote(results[\"host\"])\n\n            # store targets\n            results[\"targets\"] = NotifyBulkVS.split_path(results[\"fullpath\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyBulkVS.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyBulkVS.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/burstsms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Sign-up with https://burstsms.com/\n#\n# Define your API Secret here and acquire your API Key\n#  - https://can.transmitsms.com/profile\n#\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_bool,\n    parse_phone_no,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n\nclass BurstSMSCountryCode:\n    # Australia\n    AU = \"au\"\n    # New Zeland\n    NZ = \"nz\"\n    # United Kingdom\n    UK = \"gb\"\n    # United States\n    US = \"us\"\n\n\nBURST_SMS_COUNTRY_CODES = (\n    BurstSMSCountryCode.AU,\n    BurstSMSCountryCode.NZ,\n    BurstSMSCountryCode.UK,\n    BurstSMSCountryCode.US,\n)\n\n\nclass NotifyBurstSMS(NotifyBase):\n    \"\"\"A wrapper for Burst SMS Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Burst SMS\"\n\n    # The services URL\n    service_url = \"https://burstsms.com/\"\n\n    # The default protocol\n    secure_protocol = \"burstsms\"\n\n    # The maximum amount of SMS Messages that can reside within a single\n    # batch transfer based on:\n    #  https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c\n    default_batch_size = 500\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/burstsms/\"\n\n    # Burst SMS uses the http protocol with JSON requests\n    notify_url = \"https://api.transmitsms.com/send-sms.json\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}:{secret}@{sender_id}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n                \"private\": True,\n            },\n            \"secret\": {\n                \"name\": _(\"API Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"sender_id\": {\n                \"name\": _(\"Sender ID\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"sender_id\",\n            },\n            \"key\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"secret\": {\n                \"alias_of\": \"secret\",\n            },\n            \"country\": {\n                \"name\": _(\"Country\"),\n                \"type\": \"choice:string\",\n                \"values\": BURST_SMS_COUNTRY_CODES,\n                \"default\": BurstSMSCountryCode.US,\n            },\n            # Validity\n            # Expire a message send if it is undeliverable (defined in minutes)\n            # If set to Zero (0); this is the default and sets the max validity\n            # period\n            \"validity\": {\"name\": _(\"validity\"), \"type\": \"int\", \"default\": 0},\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        apikey,\n        secret,\n        source,\n        targets=None,\n        country=None,\n        validity=None,\n        batch=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Burst SMS Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Burst SMS API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # API Secret (associated with project)\n        self.secret = validate_regex(\n            secret, *self.template_tokens[\"secret\"][\"regex\"]\n        )\n        if not self.secret:\n            msg = f\"An invalid Burst SMS API Secret ({secret}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if not country:\n            self.country = self.template_args[\"country\"][\"default\"]\n\n        else:\n            self.country = country.lower().strip()\n            if country not in BURST_SMS_COUNTRY_CODES:\n                msg = (\n                    f\"An invalid Burst SMS country ({country}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        # Set our Validity\n        self.validity = self.template_args[\"validity\"][\"default\"]\n        if validity:\n            try:\n                self.validity = int(validity)\n\n            except (ValueError, TypeError):\n                msg = (\n                    f\"The Burst SMS Validity specified ({validity}) is\"\n                    \" invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n        # Prepare Batch Mode Flag\n        self.batch = (\n            self.template_args[\"batch\"][\"default\"] if batch is None else batch\n        )\n\n        # The Sender ID\n        self.source = validate_regex(source)\n        if not self.source:\n            msg = f\"The Account Sender ID specified ({source}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Burst SMS Notification.\"\"\"\n\n        if not self.targets:\n            self.logger.warning(\n                \"There are no valid Burst SMS targets to notify.\"\n            )\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n        }\n\n        # Prepare our authentication\n        auth = (self.apikey, self.secret)\n\n        # Prepare our payload\n        payload = {\n            \"countrycode\": self.country,\n            \"message\": body,\n            # Sender ID\n            \"from\": self.source,\n            # The to gets populated in the loop below\n            \"to\": None,\n        }\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        for index in range(0, len(targets), batch_size):\n\n            # Prepare our user\n            payload[\"to\"] = \",\".join(self.targets[index : index + batch_size])\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Burst SMS POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Burst SMS Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=payload,\n                    headers=headers,\n                    auth=auth,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyBurstSMS.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Burst SMS notification to {} \"\n                        \"target(s): {}{}error={}.\".format(\n                            len(self.targets[index : index + batch_size]),\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        \"Sent Burst SMS notification to \"\n                        f\"{len(self.targets[index : index + batch_size])} \"\n                        \"target(s).\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending Burst SMS \"\n                    \"notification to \"\n                    f\"{len(self.targets[index : index + batch_size])} \"\n                    \"target(s).\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"country\": self.country,\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        if self.validity:\n            params[\"validity\"] = str(self.validity)\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{key}:{secret}@{source}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            key=self.pprint(self.apikey, privacy, safe=\"\"),\n            secret=self.pprint(\n                self.secret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            source=NotifyBurstSMS.quote(self.source, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyBurstSMS.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyBurstSMS.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey, self.secret, self.source)\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The hostname is our source (Sender ID)\n        results[\"source\"] = NotifyBurstSMS.unquote(results[\"host\"])\n\n        # Get any remaining targets\n        results[\"targets\"] = NotifyBurstSMS.split_path(results[\"fullpath\"])\n\n        # Get our account_side and auth_token from the user/pass config\n        results[\"apikey\"] = NotifyBurstSMS.unquote(results[\"user\"])\n        results[\"secret\"] = NotifyBurstSMS.unquote(results[\"password\"])\n\n        # API Key\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            # Extract the API Key from an argument\n            results[\"apikey\"] = NotifyBurstSMS.unquote(results[\"qsd\"][\"key\"])\n\n        # API Secret\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            # Extract the API Secret from an argument\n            results[\"secret\"] = NotifyBurstSMS.unquote(\n                results[\"qsd\"][\"secret\"]\n            )\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyBurstSMS.unquote(results[\"qsd\"][\"from\"])\n        if \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifyBurstSMS.unquote(\n                results[\"qsd\"][\"source\"]\n            )\n\n        # Support country\n        if \"country\" in results[\"qsd\"] and len(results[\"qsd\"][\"country\"]):\n            results[\"country\"] = NotifyBurstSMS.unquote(\n                results[\"qsd\"][\"country\"]\n            )\n\n        # Support validity value\n        if \"validity\" in results[\"qsd\"] and len(results[\"qsd\"][\"validity\"]):\n            results[\"validity\"] = NotifyBurstSMS.unquote(\n                results[\"qsd\"][\"validity\"]\n            )\n\n        # Get Batch Mode Flag\n        if \"batch\" in results[\"qsd\"] and len(results[\"qsd\"][\"batch\"]):\n            results[\"batch\"] = parse_bool(results[\"qsd\"][\"batch\"])\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyBurstSMS.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/chanify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Chanify\n#   1. Visit https://chanify.net/\n\n# The API URL will look something like this:\n#    https://api.chanify.net/v1/sender/token\n#\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyChanify(NotifyBase):\n    \"\"\"A wrapper for Chanify Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Chanify\")\n\n    # The services URL\n    service_url = \"https://chanify.net/\"\n\n    # The default secure protocol\n    secure_protocol = \"chanify\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/chanify/\"\n\n    # Notification URL\n    notify_url = \"https://api.chanify.net/v1/sender/{token}/\"\n\n    # Define object templates\n    templates = (\"{schema}://{token}\",)\n\n    # The title is not used\n    title_maxlen = 0\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9._-]+$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize Chanify Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Chanify token specified ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send our notification.\"\"\"\n\n        # prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # Our Message\n        payload = {\"text\": body}\n\n        self.logger.debug(\n            \"Chanify GET URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Chanify Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url.format(token=self.token),\n                data=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyChanify.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Chanify notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Chanify notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Chanify notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Prepare our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{token}/?{params}\".format(\n            schema=self.secure_protocol,\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            params=NotifyChanify.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        # parse_url already handles getting the `user` and `password` fields\n        # populated.\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Allow over-ride\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyChanify.unquote(results[\"qsd\"][\"token\"])\n\n        else:\n            results[\"token\"] = NotifyChanify.unquote(results[\"host\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/clickatell.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom itertools import chain\n\n# To use this service you will need a Clickatell account to which you can get\n# your API_TOKEN at:\n#     https://www.clickatell.com/\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyClickatell(NotifyBase):\n    \"\"\"A wrapper for Clickatell Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Clickatell\")\n\n    # The services URL\n    service_url = \"https://www.clickatell.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"clickatell\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/clickatell/\"\n\n    # Clickatell API Endpoint\n    notify_url = \"https://platform.clickatell.com/messages/http/send\"\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    templates = (\n        \"{schema}://{apikey}/{targets}\",\n        \"{schema}://{source}@{apikey}/{targets}\",\n    )\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"source\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"apikey\": {\"alias_of\": \"apikey\"},\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"from\": {\n                \"alias_of\": \"source\",\n            },\n        },\n    )\n\n    def __init__(self, apikey, source=None, targets=None, **kwargs):\n        \"\"\"Initialize Clickatell Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid Clickatell API Token ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.source = None\n        if source:\n            result = is_phone_no(source)\n            if not result:\n                msg = (\n                    \"The Account (From) Phone # specified \"\n                    f\"({source}) is invalid.\"\n                )\n                self.logger.warning(msg)\n\n                raise TypeError(msg)\n\n            # Tidy source\n            self.source = result[\"full\"]\n\n        # Used for URL generation afterwards only\n        self._invalid_targets = []\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets, prefix=True):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                self._invalid_targets.append(target)\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.apikey, self.source)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{source}{apikey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            source=f\"{self.source}@\" if self.source else \"\",\n            apikey=self.pprint(self.apikey, privacy, safe=\"=\"),\n            targets=\"/\".join([\n                NotifyClickatell.quote(t, safe=\"\")\n                for t in chain(self.targets, self._invalid_targets)\n            ]),\n            params=self.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\n\n        Always return 1 at least\n        \"\"\"\n        return len(self.targets) if self.targets else 1\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Clickatell Notification.\"\"\"\n\n        if not self.targets:\n            # There were no targets to notify\n            self.logger.warning(\"There were no Clickatell targets to notify\")\n            return False\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        params_base = {\n            \"apiKey\": self.apikey,\n            \"from\": self.source,\n            \"content\": body,\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        for target in self.targets:\n            params = params_base.copy()\n            params[\"to\"] = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Clickatell GET URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Clickatell Payload: {params}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.get(\n                    self.notify_url,\n                    params=params,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if (\n                    r.status_code != requests.codes.ok\n                    and r.status_code != requests.codes.accepted\n                ):\n                    # We had a problem\n                    status_str = self.http_response_code_lookup(r.status_code)\n\n                    self.logger.warning(\n                        \"Failed to send Clickatell notification: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        \"Sent Clickatell notification to %s\", target\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Clickatell: to %s \",\n                    target,\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't parse the URL\n            return results\n\n        results[\"targets\"] = NotifyClickatell.split_path(results[\"fullpath\"])\n        results[\"apikey\"] = NotifyClickatell.unquote(results[\"host\"])\n\n        if results[\"user\"]:\n            results[\"source\"] = NotifyClickatell.unquote(results[\"user\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyClickatell.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyClickatell.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/clicksend.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, simply signup with clicksend:\n#  https://www.clicksend.com/\n#\n# You're done at this point, you only need to know your user/pass that\n# you signed up with.\n\n#  The following URLs would be accepted by Apprise:\n#   - clicksend://{user}:{password}@{phoneno}\n#   - clicksend://{user}:{password}@{phoneno1}/{phoneno2}\n\n# The API reference used to build this plugin was documented here:\n#  https://developers.clicksend.com/docs/rest/v3/\n#\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_bool, parse_phone_no\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nCLICKSEND_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n\nclass NotifyClickSend(NotifyBase):\n    \"\"\"A wrapper for ClickSend Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"ClickSend\"\n\n    # The services URL\n    service_url = \"https://clicksend.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"clicksend\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/clicksend/\"\n\n    # ClickSend uses the http protocol with JSON requests\n    notify_url = \"https://rest.clicksend.com/v3/sms/send\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # The maximum SMS batch size accepted by the ClickSend API\n    default_batch_size = 1000\n\n    # Define object templates\n    templates = (\"{schema}://{user}:{apikey}@{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"map_to\": \"password\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"key\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(self, targets=None, batch=False, **kwargs):\n        \"\"\"Initialize ClickSend Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        # Parse our targets\n        self.targets = []\n\n        if not (self.user and self.password):\n            msg = \"A ClickSend user/pass was not provided.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform ClickSend Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no ClickSend targets to notify.\")\n            return False\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json; charset=utf-8\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # prepare JSON Object\n        payload = {\"messages\": []}\n\n        # Send in batches if identified to do so\n        default_batch_size = 1 if not self.batch else self.default_batch_size\n\n        for index in range(0, len(self.targets), default_batch_size):\n            payload[\"messages\"] = [\n                {\n                    \"source\": \"php\",\n                    \"body\": body,\n                    \"to\": f\"+{to}\",\n                }\n                for to in self.targets[index : index + default_batch_size]\n            ]\n\n            self.logger.debug(\n                \"ClickSend POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"ClickSend Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    auth=(self.user, self.password),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyClickSend.http_response_code_lookup(\n                        r.status_code, CLICKSEND_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send {} ClickSend notification{}: \"\n                        \"{}{}error={}.\".format(\n                            len(payload[\"messages\"]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if default_batch_size == 1\n                                else \"(s)\"\n                            ),\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        \"Sent {} ClickSend notification{}.\".format(\n                            len(payload[\"messages\"]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if default_batch_size == 1\n                                else \"(s)\"\n                            ),\n                        )\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending {} ClickSend \"\n                    \"notification(s).\".format(len(payload[\"messages\"]))\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Setup Authentication\n        auth = \"{user}:{password}@\".format(\n            user=NotifyClickSend.quote(self.user, safe=\"\"),\n            password=self.pprint(\n                self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n        )\n\n        return \"{schema}://{auth}{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            auth=auth,\n            targets=\"/\".join(\n                [NotifyClickSend.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyClickSend.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user, self.password)\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # All elements are targets\n        results[\"targets\"] = [NotifyClickSend.unquote(results[\"host\"])]\n\n        # All entries after the hostname are additional targets\n        results[\"targets\"].extend(\n            NotifyClickSend.split_path(results[\"fullpath\"])\n        )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(results[\"qsd\"].get(\"batch\", False))\n\n        # API Key\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            # Extract the API Key from an argument\n            results[\"password\"] = NotifyClickSend.unquote(\n                results[\"qsd\"][\"key\"]\n            )\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyClickSend.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/custom_form.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport re\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom .base import NotifyBase\n\n\nclass FORMPayloadField:\n    \"\"\"Identifies the fields available in the FORM Payload.\"\"\"\n\n    VERSION = \"version\"\n    TITLE = \"title\"\n    MESSAGE = \"message\"\n    MESSAGETYPE = \"type\"\n\n\n# Defines the method to send the notification\nMETHODS = (\n    \"POST\", \"GET\", \"DELETE\", \"PUT\", \"HEAD\", \"PATCH\", \"UPDATE\", \"OPTIONS\")\n\n\nclass NotifyForm(NotifyBase):\n    \"\"\"A wrapper for Form Notifications.\"\"\"\n\n    # Support\n    # - file*\n    # - file?\n    # - file*name\n    # - file?name\n    # - ?file\n    # - *file\n    # - file\n    # The code will convert the ? or * to the digit increments\n    __attach_as_re = re.compile(\n        r\"((?P<match1>(?P<id1a>[a-z0-9_-]+)?\"\n        r\"(?P<wc1>[*?+$:.%]+)(?P<id1b>[a-z0-9_-]+))\"\n        r\"|(?P<match2>(?P<id2>[a-z0-9_-]+)(?P<wc2>[*?+$:.%]?)))\",\n        re.IGNORECASE,\n    )\n\n    # Our count\n    attach_as_count = \"{:02d}\"\n\n    # the default attach_as value\n    attach_as_default = f\"file{attach_as_count}\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Form\"\n\n    # The default protocol\n    protocol = \"form\"\n\n    # The default secure protocol\n    secure_protocol = \"forms\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/form/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # Disable throttle rate for Form requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Define the FORM version to place in all payloads\n    # Version: Major.Minor,  Major is only updated if the entire schema is\n    # changed. If just adding new items (or removing old ones, only increment\n    # the Minor!\n    form_version = \"1.0\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{user}@{host}\",\n        \"{schema}://{user}@{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n    )\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"method\": {\n                \"name\": _(\"Fetch Method\"),\n                \"type\": \"choice:string\",\n                \"values\": METHODS,\n                \"default\": METHODS[0],\n            },\n            \"attach-as\": {\n                \"name\": _(\"Attach File As\"),\n                \"type\": \"string\",\n                \"default\": \"file*\",\n                \"map_to\": \"attach_as\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n        \"payload\": {\n            \"name\": _(\"Payload Extras\"),\n            \"prefix\": \":\",\n        },\n        \"params\": {\n            \"name\": _(\"GET Params\"),\n            \"prefix\": \"-\",\n        },\n    }\n\n    def __init__(\n        self,\n        headers=None,\n        method=None,\n        payload=None,\n        params=None,\n        attach_as=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Form Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"\"\n\n        self.method = (\n            self.template_args[\"method\"][\"default\"]\n            if not isinstance(method, str)\n            else method.upper()\n        )\n\n        if self.method not in METHODS:\n            msg = f\"The method specified ({method}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Custom File Attachment Over-Ride Support\n        if not isinstance(attach_as, str):\n            # Default value\n            self.attach_as = self.attach_as_default\n            self.attach_multi_support = True\n\n        else:\n            result = self.__attach_as_re.match(attach_as.strip())\n            if not result:\n                msg = f\"The attach-as specified ({attach_as}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            self.attach_as = \"\"\n            self.attach_multi_support = False\n            if result.group(\"match1\"):\n                if result.group(\"id1a\"):\n                    self.attach_as += result.group(\"id1a\")\n\n                self.attach_as += self.attach_as_count\n                self.attach_multi_support = True\n                self.attach_as += result.group(\"id1b\")\n\n            else:  # result.group('match2'):\n                self.attach_as += result.group(\"id2\")\n                if result.group(\"wc2\"):\n                    self.attach_as += self.attach_as_count\n                    self.attach_multi_support = True\n\n        # A payload map allows users to over-ride the default mapping if\n        # they're detected with the :overide=value.  Normally this would\n        # create a new key and assign it the value specified.  However\n        # if the key you specify is actually an internally mapped one,\n        # then a re-mapping takes place using the value\n        self.payload_map = {\n            FORMPayloadField.VERSION: FORMPayloadField.VERSION,\n            FORMPayloadField.TITLE: FORMPayloadField.TITLE,\n            FORMPayloadField.MESSAGE: FORMPayloadField.MESSAGE,\n            FORMPayloadField.MESSAGETYPE: FORMPayloadField.MESSAGETYPE,\n        }\n\n        self.params = {}\n        if params:\n            # Store our extra headers\n            self.params.update(params)\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        self.payload_overrides = {}\n        self.payload_extras = {}\n        if payload:\n            # Store our extra payload entries\n            self.payload_extras.update(payload)\n            for key in list(self.payload_extras.keys()):\n                # Any values set in the payload to alter a system related one\n                # alters the system key.  Hence :message=msg maps the 'message'\n                # variable that otherwise already contains the payload to be\n                # 'msg' instead (containing the payload)\n                if key in self.payload_map:\n                    self.payload_map[key] = self.payload_extras[key]\n                    self.payload_overrides[key] = self.payload_extras[key]\n                    del self.payload_extras[key]\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Form Notification.\"\"\"\n\n        # Prepare HTTP Headers\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        # Track our potential attachments\n        files = []\n        if attach and self.attachment_support:\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    files.append((\n                        (\n                            self.attach_as.format(no)\n                            if self.attach_multi_support\n                            else self.attach_as\n                        ),\n                        (\n                            (\n                                attachment.name\n                                if attachment.name\n                                else f\"file{no:03}.dat\"\n                            ),\n                            # file handle is safely closed in `finally`; inline\n                            # open is intentional\n                            open(attachment.path, \"rb\"),  # noqa: SIM115\n                            attachment.mimetype,\n                        ),\n                    ))\n\n                except OSError as e:\n                    self.logger.warning(\n                        \"An I/O error occurred while opening {}.\".format(\n                            attachment.name if attachment else \"attachment\"\n                        )\n                    )\n                    self.logger.debug(f\"I/O Exception: {e!s}\")\n                    return False\n\n            if not self.attach_multi_support and no > 1:\n                self.logger.warning(\n                    \"Multiple attachments provided while \"\n                    \"form:// Multi-Attachment Support not enabled\"\n                )\n\n        # prepare Form Object\n        payload = {}\n\n        for key, value in (\n            (FORMPayloadField.VERSION, self.form_version),\n            (FORMPayloadField.TITLE, title),\n            (FORMPayloadField.MESSAGE, body),\n            (FORMPayloadField.MESSAGETYPE, notify_type.value),\n        ):\n\n            if not self.payload_map[key]:\n                # Do not store element in payload response\n                continue\n            payload[self.payload_map[key]] = value\n\n        # Apply any/all payload over-rides defined\n        payload.update(self.payload_extras)\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        url += self.fullpath\n\n        self.logger.debug(\n            f\"Form {self.method} URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Form Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # For GET the payload becomes URL query parameters; for all other\n        # methods it is sent as the request body.\n        if self.method == \"GET\":\n            payload.update(self.params)\n\n        try:\n            r = requests.request(\n                self.method,\n                url,\n                files=files if files else None,\n                data=payload if self.method != \"GET\" else None,\n                params=payload if self.method == \"GET\" else self.params,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code < 200 or r.status_code >= 300:\n                # We had a problem\n                status_str = NotifyForm.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Form %s notification: %s%serror=%s.\",\n                    self.method,\n                    status_str,\n                    \", \" if status_str else \"\",\n                    r.status_code,\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Form %s notification.\", self.method)\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Form \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while reading one of the \"\n                \"attached files.\"\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return False\n\n        finally:\n            for file in files:\n                # Ensure all files are closed\n                file[1][1].close()\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n            self.fullpath.rstrip(\"/\"),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"method\": self.method,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Append our GET params into our parameters\n        params.update({f\"-{k}\": v for k, v in self.params.items()})\n\n        # Append our payload extra's into our parameters\n        params.update({f\":{k}\": v for k, v in self.payload_extras.items()})\n        params.update({f\":{k}\": v for k, v in self.payload_overrides.items()})\n\n        if self.attach_as != self.attach_as_default:\n            # Provide Attach-As extension details\n            params[\"attach-as\"] = self.attach_as\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyForm.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyForm.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}{fullpath}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=(\n                NotifyForm.quote(self.fullpath, safe=\"/\")\n                if self.fullpath\n                else \"/\"\n            ),\n            params=NotifyForm.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # store any additional payload extra's defined\n        results[\"payload\"] = {\n            NotifyForm.unquote(x): NotifyForm.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyForm.unquote(x): NotifyForm.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Add our GET paramters in the event the user wants to pass these along\n        results[\"params\"] = {\n            NotifyForm.unquote(x): NotifyForm.unquote(y)\n            for x, y in results[\"qsd-\"].items()\n        }\n\n        # Allow Attach-As Support which over-rides the name of the filename\n        # posted with the form://\n        # the default is file01, file02, file03, etc\n        if \"attach-as\" in results[\"qsd\"] and len(results[\"qsd\"][\"attach-as\"]):\n            results[\"attach_as\"] = results[\"qsd\"][\"attach-as\"]\n\n        # Set method if not otherwise set\n        if \"method\" in results[\"qsd\"] and len(results[\"qsd\"][\"method\"]):\n            results[\"method\"] = NotifyForm.unquote(results[\"qsd\"][\"method\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/custom_json.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\nimport logging\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n\nclass JSONPayloadField:\n    \"\"\"Identifies the fields available in the JSON Payload.\"\"\"\n\n    VERSION = \"version\"\n    TITLE = \"title\"\n    MESSAGE = \"message\"\n    ATTACHMENTS = \"attachments\"\n    MESSAGETYPE = \"type\"\n\n\n# Defines the method to send the notification\nMETHODS = (\n    \"POST\", \"GET\", \"DELETE\", \"PUT\", \"HEAD\", \"PATCH\", \"UPDATE\", \"OPTIONS\")\n\n\nclass NotifyJSON(NotifyBase):\n    \"\"\"A wrapper for JSON Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"JSON\"\n\n    # The default protocol\n    protocol = \"json\"\n\n    # The default secure protocol\n    secure_protocol = \"jsons\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/json/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # Disable throttle rate for JSON requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Define the JSON version to place in all payloads\n    # Version: Major.Minor,  Major is only updated if the entire schema is\n    # changed. If just adding new items (or removing old ones, only increment\n    # the Minor!\n    json_version = \"1.0\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{user}@{host}\",\n        \"{schema}://{user}@{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n    )\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"method\": {\n                \"name\": _(\"Fetch Method\"),\n                \"type\": \"choice:string\",\n                \"values\": METHODS,\n                \"default\": METHODS[0],\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n        \"payload\": {\n            \"name\": _(\"Payload Extras\"),\n            \"prefix\": \":\",\n        },\n        \"params\": {\n            \"name\": _(\"GET Params\"),\n            \"prefix\": \"-\",\n        },\n    }\n\n    def __init__(\n        self, headers=None, method=None, payload=None, params=None, **kwargs\n    ):\n        \"\"\"Initialize JSON Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"\"\n\n        self.method = (\n            self.template_args[\"method\"][\"default\"]\n            if not isinstance(method, str)\n            else method.upper()\n        )\n\n        if self.method not in METHODS:\n            msg = f\"The method specified ({method}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.params = {}\n        if params:\n            # Store our extra headers\n            self.params.update(params)\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        self.payload_extras = {}\n        if payload:\n            # Store our extra payload entries\n            self.payload_extras.update(payload)\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform JSON Notification.\"\"\"\n\n        # Prepare HTTP Headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        # Track our potential attachments\n        attachments = []\n        if attach and self.attachment_support:\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Custom JSON attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    attachments.append({\n                        \"filename\": (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                        \"base64\": attachment.base64(),\n                        \"mimetype\": attachment.mimetype,\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Custom JSON attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending Custom JSON attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        # Prepare JSON Object\n        payload = {\n            JSONPayloadField.VERSION: self.json_version,\n            JSONPayloadField.TITLE: title,\n            JSONPayloadField.MESSAGE: body,\n            JSONPayloadField.ATTACHMENTS: attachments,\n            JSONPayloadField.MESSAGETYPE: notify_type.value,\n        }\n\n        for key, value in self.payload_extras.items():\n\n            if key in payload:\n                if not value:\n                    # Do not store element in payload response\n                    del payload[key]\n\n                else:\n                    # Re-map\n                    payload[value] = payload[key]\n                    del payload[key]\n\n            else:\n                # Append entry\n                payload[key] = value\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        url += self.fullpath\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                f\"JSON POST URL: {url} \"\n                f\"(cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(\"JSON Payload: %s\", sanitize_payload(payload))\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.request(\n                self.method,\n                url,\n                data=dumps(payload),\n                params=self.params,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code < 200 or r.status_code >= 300:\n                # We had a problem\n                status_str = NotifyJSON.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send JSON %s notification: %s%serror=%s.\",\n                    self.method,\n                    status_str,\n                    \", \" if status_str else \"\",\n                    r.status_code,\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent JSON %s notification.\", self.method)\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending JSON \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n            self.fullpath.rstrip(\"/\"),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"method\": self.method,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Append our GET params into our parameters\n        params.update({f\"-{k}\": v for k, v in self.params.items()})\n\n        # Append our payload extra's into our parameters\n        params.update({f\":{k}\": v for k, v in self.payload_extras.items()})\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyJSON.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyJSON.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}{fullpath}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=(\n                NotifyJSON.quote(self.fullpath, safe=\"/\")\n                if self.fullpath\n                else \"/\"\n            ),\n            params=NotifyJSON.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # store any additional payload extra's defined\n        results[\"payload\"] = {\n            NotifyJSON.unquote(x): NotifyJSON.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyJSON.unquote(x): NotifyJSON.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Add our GET paramters in the event the user wants to pass these along\n        results[\"params\"] = {\n            NotifyJSON.unquote(x): NotifyJSON.unquote(y)\n            for x, y in results[\"qsd-\"].items()\n        }\n\n        # Set method if not otherwise set\n        if \"method\" in results[\"qsd\"] and len(results[\"qsd\"][\"method\"]):\n            results[\"method\"] = NotifyJSON.unquote(results[\"qsd\"][\"method\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/custom_xml.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport re\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n\nclass XMLPayloadField:\n    \"\"\"Identifies the fields available in the JSON Payload.\"\"\"\n\n    VERSION = \"Version\"\n    TITLE = \"Subject\"\n    MESSAGE = \"Message\"\n    MESSAGETYPE = \"MessageType\"\n\n\n# Defines the method to send the notification\nMETHODS = (\n    \"POST\", \"GET\", \"DELETE\", \"PUT\", \"HEAD\", \"PATCH\", \"UPDATE\", \"OPTIONS\")\n\n\nclass NotifyXML(NotifyBase):\n    \"\"\"A wrapper for XML Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"XML\"\n\n    # The default protocol\n    protocol = \"xml\"\n\n    # The default secure protocol\n    secure_protocol = \"xmls\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/xml/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # Disable throttle rate for JSON requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # XSD Information\n    xsd_ver = \"1.1\"\n    xsd_default_url = (\n        \"https://raw.githubusercontent.com/caronc/apprise/master\"\n        \"/apprise/assets/NotifyXML-{version}.xsd\"\n    )\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{user}@{host}\",\n        \"{schema}://{user}@{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n    )\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"method\": {\n                \"name\": _(\"Fetch Method\"),\n                \"type\": \"choice:string\",\n                \"values\": METHODS,\n                \"default\": METHODS[0],\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n        \"payload\": {\n            \"name\": _(\"Payload Extras\"),\n            \"prefix\": \":\",\n        },\n        \"params\": {\n            \"name\": _(\"GET Params\"),\n            \"prefix\": \"-\",\n        },\n    }\n\n    def __init__(\n        self, headers=None, method=None, payload=None, params=None, **kwargs\n    ):\n        \"\"\"Initialize XML Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.payload = \"\"\"<?xml version='1.0' encoding='utf-8'?>\n<soapenv:Envelope\n    xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\"\n    xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\"\n    xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\">\n    <soapenv:Body>\n        <Notification{{XSD_URL}}>\n            {{CORE}}\n            {{ATTACHMENTS}}\n       </Notification>\n    </soapenv:Body>\n</soapenv:Envelope>\"\"\"\n\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"\"\n\n        self.method = (\n            self.template_args[\"method\"][\"default\"]\n            if not isinstance(method, str)\n            else method.upper()\n        )\n\n        if self.method not in METHODS:\n            msg = f\"The method specified ({method}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # A payload map allows users to over-ride the default mapping if\n        # they're detected with the :overide=value.  Normally this would\n        # create a new key and assign it the value specified.  However\n        # if the key you specify is actually an internally mapped one,\n        # then a re-mapping takes place using the value\n        self.payload_map = {\n            XMLPayloadField.VERSION: XMLPayloadField.VERSION,\n            XMLPayloadField.TITLE: XMLPayloadField.TITLE,\n            XMLPayloadField.MESSAGE: XMLPayloadField.MESSAGE,\n            XMLPayloadField.MESSAGETYPE: XMLPayloadField.MESSAGETYPE,\n        }\n\n        self.params = {}\n        if params:\n            # Store our extra headers\n            self.params.update(params)\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        self.payload_overrides = {}\n        self.payload_extras = {}\n        if payload:\n            # Store our extra payload entries (but tidy them up since they will\n            # become XML Keys (they can't contain certain characters\n            for k, v in payload.items():\n                key = re.sub(r\"[^A-Za-z0-9_-]*\", \"\", k)\n                if not key:\n                    self.logger.warning(\n                        f\"Ignoring invalid XML Stanza element name({k})\"\n                    )\n                    continue\n\n                # Any values set in the payload to alter a system related one\n                # alters the system key.  Hence :message=msg maps the 'message'\n                # variable that otherwise already contains the payload to be\n                # 'msg' instead (containing the payload)\n                if key in self.payload_map:\n                    self.payload_map[key] = v\n                    self.payload_overrides[key] = v\n\n                else:\n                    self.payload_extras[key] = v\n\n        # Set our xsd url\n        self.xsd_url = (\n            None\n            if self.payload_overrides or self.payload_extras\n            else self.xsd_default_url.format(version=self.xsd_ver)\n        )\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform XML Notification.\"\"\"\n\n        # Prepare HTTP Headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/xml\",\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        # Our XML Attachmement subsitution\n        xml_attachments = \"\"\n\n        payload_base = {}\n\n        for key, value in (\n            (XMLPayloadField.VERSION, self.xsd_ver),\n            (\n                XMLPayloadField.TITLE,\n                NotifyXML.escape_html(title, whitespace=False),\n            ),\n            (\n                XMLPayloadField.MESSAGE,\n                NotifyXML.escape_html(body, whitespace=False),\n            ),\n            (\n                XMLPayloadField.MESSAGETYPE,\n                NotifyXML.escape_html(notify_type.value, whitespace=False),\n            ),\n        ):\n\n            if not self.payload_map[key]:\n                # Do not store element in payload response\n                continue\n            payload_base[self.payload_map[key]] = value\n\n        # Apply our payload extras\n        payload_base.update({\n            k: NotifyXML.escape_html(v, whitespace=False)\n            for k, v in self.payload_extras.items()\n        })\n\n        # Base Entres\n        xml_base = \"\".join(\n            [f\"<{k}>{v}</{k}>\" for k, v in payload_base.items()]\n        )\n\n        attachments = []\n        if attach and self.attachment_support:\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Custom XML attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    # Prepare our Attachment in Base64\n                    entry = '<Attachment filename=\"{}\" mimetype=\"{}\">'.format(\n                        NotifyXML.escape_html(\n                            (\n                                attachment.name\n                                if attachment.name\n                                else f\"file{no:03}.dat\"\n                            ),\n                            whitespace=False,\n                        ),\n                        NotifyXML.escape_html(\n                            attachment.mimetype, whitespace=False\n                        ),\n                    )\n                    entry += attachment.base64()\n                    entry += \"</Attachment>\"\n                    attachments.append(entry)\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Custom XML attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending Custom XML attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n            # Update our xml_attachments record:\n            xml_attachments = (\n                '<Attachments format=\"base64\">'\n                + \"\".join(attachments)\n                + \"</Attachments>\"\n            )\n\n        re_map = {\n            \"{{XSD_URL}}\": (\n                f' xmlns:xsi=\"{self.xsd_url}\"' if self.xsd_url else \"\"\n            ),\n            \"{{ATTACHMENTS}}\": xml_attachments,\n            \"{{CORE}}\": xml_base,\n        }\n\n        # Iterate over above list and store content accordingly\n        re_table = re.compile(\n            r\"(\" + \"|\".join(re_map.keys()) + r\")\",\n            re.IGNORECASE,\n        )\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        url += self.fullpath\n        payload = re_table.sub(lambda x: re_map[x.group()], self.payload)\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                f\"XML POST URL: {url} \"\n                f\"(cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(\n                \"XML Payload: %s\", sanitize_payload(payload))\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.request(\n                self.method,\n                url,\n                data=payload,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code < 200 or r.status_code >= 300:\n                # We had a problem\n                status_str = NotifyXML.http_response_code_lookup(r.status_code)\n\n                self.logger.warning(\n                    \"Failed to send JSON %s notification: %s%serror=%s.\",\n                    self.method,\n                    status_str,\n                    \", \" if status_str else \"\",\n                    r.status_code,\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent XML %s notification.\", self.method)\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending XML \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n            self.fullpath.rstrip(\"/\"),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"method\": self.method,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Append our GET params into our parameters\n        params.update({f\"-{k}\": v for k, v in self.params.items()})\n\n        # Append our payload extra's into our parameters\n        params.update({f\":{k}\": v for k, v in self.payload_extras.items()})\n        params.update({f\":{k}\": v for k, v in self.payload_overrides.items()})\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyXML.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyXML.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}{fullpath}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=(\n                NotifyXML.quote(self.fullpath, safe=\"/\")\n                if self.fullpath\n                else \"/\"\n            ),\n            params=NotifyXML.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # store any additional payload extra's defined\n        results[\"payload\"] = {\n            NotifyXML.unquote(x): NotifyXML.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyXML.unquote(x): NotifyXML.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Add our GET paramters in the event the user wants to pass these along\n        results[\"params\"] = {\n            NotifyXML.unquote(x): NotifyXML.unquote(y)\n            for x, y in results[\"qsd-\"].items()\n        }\n\n        # Set method if not otherwise set\n        if \"method\" in results[\"qsd\"] and len(results[\"qsd\"][\"method\"]):\n            results[\"method\"] = NotifyXML.unquote(results[\"qsd\"][\"method\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/d7networks.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this service you will need a D7 Networks account from their website\n# at https://d7networks.com/\n#\n# After you've established your account you can get your api login credentials\n# (both user and password) from the API Details section from within your\n# account profile area:  https://d7networks.com/accounts/profile/\n#\n# API Reference: https://d7networks.com/docs/Messages/Send_Message/\n\nfrom json import dumps, loads\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_bool,\n    parse_phone_no,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nD7NETWORKS_HTTP_ERROR_MAP = {\n    401: \"Invalid Argument(s) Specified.\",\n    403: \"Unauthorized - Authentication Failure.\",\n    412: \"A Routing Error Occured\",\n    500: \"A Serverside Error Occured Handling the Request.\",\n}\n\n\nclass NotifyD7Networks(NotifyBase):\n    \"\"\"A wrapper for D7 Networks Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"D7 Networks\"\n\n    # The services URL\n    service_url = \"https://d7networks.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"d7sms\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/d7networks/\"\n\n    # D7 Networks single notification URL\n    notify_url = \"https://api.d7networks.com/messages/v1/send\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{token}@{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"API Access Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"source\": {\n                # Originating address,In cases where the rewriting of the\n                # sender's address is supported or permitted by the SMS-C.\n                # This is used to transmit the message, this number is\n                # transmitted as the originating address and is completely\n                # optional.\n                \"name\": _(\"Originating Address\"),\n                \"type\": \"string\",\n                \"map_to\": \"source\",\n            },\n            \"from\": {\n                \"alias_of\": \"source\",\n            },\n            \"unicode\": {\n                # Unicode characters (default is 'auto')\n                \"name\": _(\"Unicode Characters\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        token=None,\n        targets=None,\n        source=None,\n        batch=False,\n        unicode=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize D7 Networks Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        # Setup our source address (if defined)\n        self.source = None if not isinstance(source, str) else source.strip()\n\n        # Define whether or not we should set the unicode flag\n        self.unicode = (\n            self.template_args[\"unicode\"][\"default\"]\n            if unicode is None\n            else bool(unicode)\n        )\n\n        # The token associated with the account\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = f\"The D7 Networks token specified ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Parse our targets\n        self.targets = []\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Depending on whether we are set to batch mode or single mode this\n        redirects to the appropriate handling.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no D7 Networks targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.token}\",\n        }\n\n        payload = {\n            \"message_globals\": {\n                \"channel\": \"sms\",\n            },\n            \"messages\": [{\n                # Populated later on\n                \"recipients\": None,\n                \"content\": body,\n                \"data_coding\":\n                # auto is a better substitute over 'text' as text is easier to\n                # detect from a post than `unicode` is.\n                \"auto\" if not self.unicode else \"unicode\",\n            }],\n        }\n\n        # use the list directly\n        targets = list(self.targets)\n\n        if self.source:\n            payload[\"message_globals\"][\"originator\"] = self.source\n\n        target = None\n        while len(targets):\n\n            if self.batch:\n                # Prepare our payload\n                payload[\"messages\"][0][\"recipients\"] = self.targets\n\n                # Reset our targets so we don't keep going. This is required\n                # because we're in batch mode; we only need to loop once.\n                targets = []\n\n            else:\n                # We're not in a batch mode; so get our next target\n                # Get our target(s) to notify\n                target = targets.pop(0)\n\n                # Prepare our payload\n                payload[\"messages\"][0][\"recipients\"] = [target]\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"D7 Networks POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"D7 Networks Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code not in (\n                    requests.codes.created,\n                    requests.codes.ok,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code, D7NETWORKS_HTTP_ERROR_MAP\n                    )\n\n                    try:\n                        # Update our status response if we can\n                        json_response = loads(r.content)\n                        status_str = json_response.get(\"message\", status_str)\n\n                    except (AttributeError, TypeError, ValueError):\n                        # ValueError = r.content is Unparsable\n                        # TypeError = r.content is None\n                        # AttributeError = r is None\n\n                        # We could not parse JSON response.\n                        # We will just use the status we already have.\n                        pass\n\n                    self.logger.warning(\n                        \"Failed to send D7 Networks SMS notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            \", \".join(target) if self.batch else target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n\n                    if self.batch:\n                        self.logger.info(\n                            \"Sent D7 Networks batch SMS notification to \"\n                            f\"{len(self.targets)} target(s).\"\n                        )\n\n                    else:\n                        self.logger.info(\n                            f\"Sent D7 Networks SMS notification to {target}.\"\n                        )\n\n                    self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending D7 Networks:{} \"\n                    .format(\", \".join(self.targets))\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"unicode\": \"yes\" if self.unicode else \"no\",\n        }\n\n        if self.source:\n            params[\"from\"] = self.source\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{token}@{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyD7Networks.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyD7Networks.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        return len(self.targets) if not self.batch else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyD7Networks.unquote(\n                results[\"qsd\"][\"token\"]\n            )\n\n        elif results[\"user\"]:\n            results[\"token\"] = NotifyD7Networks.unquote(results[\"user\"])\n\n            if results[\"password\"]:\n                # Support token containing a colon (:)\n                results[\"token\"] += \":\" + NotifyD7Networks.unquote(\n                    results[\"password\"]\n                )\n\n        elif results[\"password\"]:\n            # Support token starting with a colon (:)\n            results[\"token\"] = \":\" + NotifyD7Networks.unquote(\n                results[\"password\"]\n            )\n\n        # Initialize our targets\n        results[\"targets\"] = []\n\n        # The store our first target stored in the hostname\n        results[\"targets\"].append(NotifyD7Networks.unquote(results[\"host\"]))\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"].extend(\n            NotifyD7Networks.split_path(results[\"fullpath\"])\n        )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(results[\"qsd\"].get(\"batch\", False))\n\n        # Get Unicode Flag\n        results[\"unicode\"] = parse_bool(results[\"qsd\"].get(\"unicode\", False))\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyD7Networks.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Support the 'from' and source variable\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyD7Networks.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n        elif \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifyD7Networks.unquote(\n                results[\"qsd\"][\"source\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/dapnet.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, sign up with Hampager (you need to be a licensed\n# ham radio operator\n#  http://www.hampager.de/\n#\n# You're done at this point, you only need to know your user/pass that\n# you signed up with.\n\n#  The following URLs would be accepted by Apprise:\n#   - dapnet://{user}:{password}@{callsign}\n#   - dapnet://{user}:{password}@{callsign1}/{callsign2}\n\n# Optional parameters:\n#   - priority (NORMAL or EMERGENCY). Default: NORMAL\n#   - txgroups --> comma-separated list of DAPNET transmitter\n#                           groups. Default: 'dl-all'\n#                           https://hampager.de/#/transmitters/groups\n\nfrom json import dumps\n\n# The API reference used to build this plugin was documented here:\n#  https://hampager.de/dokuwiki/doku.php#dapnet_api\n#\nimport requests\nfrom requests.auth import HTTPBasicAuth\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_call_sign, parse_bool, parse_call_sign, parse_list\nfrom .base import NotifyBase\n\n\nclass DapnetPriority:\n    NORMAL = 0\n    EMERGENCY = 1\n\n\nDAPNET_PRIORITIES = {\n    DapnetPriority.NORMAL: \"normal\",\n    DapnetPriority.EMERGENCY: \"emergency\",\n}\n\n\nDAPNET_PRIORITY_MAP = {\n    # Maps against string 'normal'\n    \"n\": DapnetPriority.NORMAL,\n    # Maps against string 'emergency'\n    \"e\": DapnetPriority.EMERGENCY,\n    # Entries to additionally support (so more like Dapnet's API)\n    \"0\": DapnetPriority.NORMAL,\n    \"1\": DapnetPriority.EMERGENCY,\n}\n\n\nclass NotifyDapnet(NotifyBase):\n    \"\"\"A wrapper for DAPNET / Hampager Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Dapnet\"\n\n    # The services URL\n    service_url = \"https://hampager.de/\"\n\n    # The default secure protocol\n    secure_protocol = \"dapnet\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/dapnet/\"\n\n    # Dapnet uses the http protocol with JSON requests\n    notify_url = \"http://www.hampager.de:8080/calls\"\n\n    # The maximum length of the body\n    body_maxlen = 80\n\n    # A title can not be used for Dapnet Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # The maximum amount of emails that can reside within a single transmission\n    default_batch_size = 50\n\n    # Define object templates\n    templates = (\"{schema}://{user}:{password}@{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_callsign\": {\n                \"name\": _(\"Target Callsign\"),\n                \"type\": \"string\",\n                \"regex\": (\n                    r\"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$\",\n                    \"i\",\n                ),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": DAPNET_PRIORITIES,\n                \"default\": DapnetPriority.NORMAL,\n            },\n            \"txgroups\": {\n                \"name\": _(\"Transmitter Groups\"),\n                \"type\": \"string\",\n                \"default\": \"dl-all\",\n                \"private\": True,\n            },\n            \"to\": {\n                \"name\": _(\"Target Callsign\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self, targets=None, priority=None, txgroups=None, batch=False, **kwargs\n    ):\n        \"\"\"Initialize Dapnet Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Parse our targets\n        self.targets = []\n\n        # The Priority of the message\n        self.priority = int(\n            NotifyDapnet.template_args[\"priority\"][\"default\"]\n            if priority is None\n            else next(\n                (\n                    v\n                    for k, v in DAPNET_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyDapnet.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        if not (self.user and self.password):\n            msg = \"A Dapnet user/pass was not provided.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Get the transmitter group\n        self.txgroups = parse_list(\n            txgroups\n            if txgroups\n            else NotifyDapnet.template_args[\"txgroups\"][\"default\"]\n        )\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        for target in parse_call_sign(targets):\n            # Validate targets and drop bad ones:\n            result = is_call_sign(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropping invalid Amateur radio call sign ({target}).\",\n                )\n                continue\n\n            # Store callsign without SSID and ignore duplicates\n            if result[\"callsign\"] not in self.targets:\n                self.targets.append(result[\"callsign\"])\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Dapnet Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\n                \"There are no Amateur radio callsigns to notify\"\n            )\n            return False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json; charset=utf-8\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        for index in range(0, len(targets), batch_size):\n\n            # prepare JSON payload\n            payload = {\n                \"text\": body,\n                \"callSignNames\": targets[index : index + batch_size],\n                \"transmitterGroupNames\": self.txgroups,\n                \"emergency\": self.priority == DapnetPriority.EMERGENCY,\n            }\n\n            self.logger.debug(f\"DAPNET POST URL: {self.notify_url}\")\n            self.logger.debug(f\"DAPNET Payload: {dumps(payload)}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    auth=HTTPBasicAuth(\n                        username=self.user, password=self.password\n                    ),\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.created:\n                    # We had a problem\n\n                    self.logger.warning(\n                        \"Failed to send DAPNET notification {} to {}: \"\n                        \"error={}.\".format(\n                            payload[\"text\"],\n                            f\" to {self.targets}\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n\n                else:\n                    self.logger.info(\n                        \"Sent '{}' DAPNET notification {}\".format(\n                            payload[\"text\"], f\"to {self.targets}\"\n                        )\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending DAPNET \"\n                    f\"notification to {self.targets}\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"priority\": (\n                DAPNET_PRIORITIES[self.template_args[\"priority\"][\"default\"]]\n                if self.priority not in DAPNET_PRIORITIES\n                else DAPNET_PRIORITIES[self.priority]\n            ),\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"txgroups\": \",\".join(self.txgroups),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Setup Authentication\n        auth = \"{user}:{password}@\".format(\n            user=NotifyDapnet.quote(self.user, safe=\"\"),\n            password=self.pprint(\n                self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n        )\n\n        return \"{schema}://{auth}{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            auth=auth,\n            targets=\"/\".join(\n                [self.pprint(x, privacy, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyDapnet.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user, self.password)\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # All elements are targets\n        results[\"targets\"] = [NotifyDapnet.unquote(results[\"host\"])]\n\n        # All entries after the hostname are additional targets\n        results[\"targets\"].extend(NotifyDapnet.split_path(results[\"fullpath\"]))\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyDapnet.parse_list(results[\"qsd\"][\"to\"])\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyDapnet.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        # Check for one or multiple transmitter groups (comma separated)\n        # and split them up, when necessary\n        if \"txgroups\" in results[\"qsd\"]:\n            results[\"txgroups\"] = [\n                x.lower()\n                for x in NotifyDapnet.parse_list(results[\"qsd\"][\"txgroups\"])\n            ]\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyDapnet.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/dbus.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport sys\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n# Default our global support flag\nNOTIFY_DBUS_SUPPORT_ENABLED = False\n\n# Image support is dependant on the GdkPixbuf library being available\nNOTIFY_DBUS_IMAGE_SUPPORT = False\n\n# Initialize our mainloops\nLOOP_GLIB = None\nLOOP_QT = None\n\n\ntry:\n    # D-Bus Message Bus Daemon 1.12.XX Essentials\n    from dbus import Byte, ByteArray, DBusException, Interface, SessionBus\n\n    #\n    # now we try to determine which mainloop(s) we can access\n    #\n\n    # glib/dbus\n    try:\n        from dbus.mainloop.glib import DBusGMainLoop\n        LOOP_GLIB = DBusGMainLoop()\n\n    except ImportError:  # pragma: no cover\n        # No problem\n        pass\n\n    # qt\n    try:\n        from dbus.mainloop.qt import DBusQtMainLoop\n        LOOP_QT = DBusQtMainLoop(set_as_default=True)\n\n    except ImportError:\n        # No problem\n        pass\n\n    # We're good as long as at least one\n    NOTIFY_DBUS_SUPPORT_ENABLED = LOOP_GLIB is not None or LOOP_QT is not None\n\n    # ImportError: When using gi.repository you must not import static modules\n    # like \"gobject\". Please change all occurrences of \"import gobject\" to\n    # \"from gi.repository import GObject\".\n    # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183\n    if \"gobject\" in sys.modules:  # pragma: no cover\n        del sys.modules[\"gobject\"]\n\n    try:\n        # The following is required for Image/Icon loading only\n        import gi\n        gi.require_version(\"GdkPixbuf\", \"2.0\")\n        from gi.repository import GdkPixbuf\n        NOTIFY_DBUS_IMAGE_SUPPORT = True\n\n    except (ImportError, ValueError, AttributeError):\n        # No problem; this will get caught in outer try/catch\n\n        # A ValueError will get thrown upon calling gi.require_version() if\n        # GDK/GTK isn't installed on the system but gi is.\n        pass\n\nexcept ImportError:\n    # No problem; we just simply can't support this plugin; we could\n    # be in microsoft windows, or we just don't have the python-gobject\n    # library available to us (or maybe one we don't support)?\n    pass\n\n# Define our supported protocols and the loop to assign them.\n# The key to value pairs are the actual supported schema's matched\n# up with the Main Loop they should reference when accessed.\nMAINLOOP_MAP = {\n    \"qt\": LOOP_QT,\n    \"kde\": LOOP_QT,\n    \"dbus\": LOOP_QT if LOOP_QT else LOOP_GLIB,\n}\n\n\n# Urgencies\nclass DBusUrgency:\n    LOW = 0\n    NORMAL = 1\n    HIGH = 2\n\n\nDBUS_URGENCIES = {\n    # Note: This also acts as a reverse lookup mapping\n    DBusUrgency.LOW: \"low\",\n    DBusUrgency.NORMAL: \"normal\",\n    DBusUrgency.HIGH: \"high\",\n}\n\nDBUS_URGENCY_MAP = {\n    # Maps against string 'low'\n    \"l\": DBusUrgency.LOW,\n    # Maps against string 'moderate'\n    \"m\": DBusUrgency.LOW,\n    # Maps against string 'normal'\n    \"n\": DBusUrgency.NORMAL,\n    # Maps against string 'high'\n    \"h\": DBusUrgency.HIGH,\n    # Maps against string 'emergency'\n    \"e\": DBusUrgency.HIGH,\n\n    # Entries to additionally support (so more like DBus's API)\n    \"0\": DBusUrgency.LOW,\n    \"1\": DBusUrgency.NORMAL,\n    \"2\": DBusUrgency.HIGH,\n}\n\n\nclass NotifyDBus(NotifyBase):\n    \"\"\"A wrapper for local DBus/Qt Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_DBUS_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"details\": _(\"libdbus-1.so.x must be installed.\")\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"DBus Notification\")\n\n    # The services URL\n    service_url = \"http://www.freedesktop.org/Software/dbus/\"\n\n    # The default protocols\n    # Python 3 keys() does not return a list object, it is its own dict_keys()\n    # object if we were to reference, we wouldn't be backwards compatible with\n    # Python v2.  So converting the result set back into a list makes us\n    # compatible\n    protocol = list(MAINLOOP_MAP.keys())\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/dbus/\"\n\n    # No throttling required for DBus queries\n    request_rate_per_sec = 0\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # The number of milliseconds to keep the message present for\n    message_timeout_ms = 13000\n\n    # Limit results to just the first 10 line otherwise there is just to much\n    # content to display\n    body_max_line_count = 10\n\n    # The following are required to hook into the notifications:\n    dbus_interface = \"org.freedesktop.Notifications\"\n    dbus_setting_location = \"/org/freedesktop/Notifications\"\n\n    # No URL Identifier will be defined for this service as there simply isn't\n    # enough details to uniquely identify one dbus:// from another.\n    url_identifier = False\n\n    # Define object templates\n    templates = (\"{schema}://\",)\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"urgency\": {\n                \"name\": _(\"Urgency\"),\n                \"type\": \"choice:int\",\n                \"values\": DBUS_URGENCIES,\n                \"default\": DBusUrgency.NORMAL,\n            },\n            \"priority\": {\n                # Apprise uses 'priority' everywhere; it's just a nice\n                # consistent feel to be able to use it here as well. Just map\n                # the value back to 'priority'\n                \"alias_of\": \"urgency\",\n            },\n            \"x\": {\n                \"name\": _(\"X-Axis\"),\n                \"type\": \"int\",\n                \"min\": 0,\n                \"map_to\": \"x_axis\",\n            },\n            \"y\": {\n                \"name\": _(\"Y-Axis\"),\n                \"type\": \"int\",\n                \"min\": 0,\n                \"map_to\": \"y_axis\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        urgency=None,\n        x_axis=None,\n        y_axis=None,\n        include_image=True,\n        **kwargs,\n    ):\n        \"\"\"Initialize DBus Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        # Track our notifications\n        self.registry = {}\n\n        # Store our schema; default to dbus\n        self.schema = kwargs.get(\"schema\", \"dbus\")\n\n        if self.schema not in MAINLOOP_MAP:\n            msg = f\"The schema specified ({self.schema}) is not supported.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The urgency of the message\n        self.urgency = int(\n            NotifyDBus.template_args[\"urgency\"][\"default\"]\n            if urgency is None\n            else next(\n                (\n                    v\n                    for k, v in DBUS_URGENCY_MAP.items()\n                    if str(urgency).lower().startswith(k)\n                ),\n                NotifyDBus.template_args[\"urgency\"][\"default\"],\n            )\n        )\n\n        # Our x/y axis settings\n        if x_axis or y_axis:\n            try:\n                self.x_axis = int(x_axis)\n                self.y_axis = int(y_axis)\n\n            except (TypeError, ValueError):\n                # Invalid x/y values specified\n                msg = (\n                    f\"The x,y coordinates specified ({x_axis},{y_axis}) are\"\n                    \" invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n        else:\n            self.x_axis = None\n            self.y_axis = None\n\n        # Track whether we want to add an image to the notification.\n        self.include_image = include_image\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform DBus Notification.\"\"\"\n        # Acquire our session\n        try:\n            session = SessionBus(mainloop=MAINLOOP_MAP[self.schema])\n\n        except DBusException as e:\n            # Handle exception\n            self.logger.warning(\"Failed to send DBus notification.\")\n            self.logger.debug(f\"DBus Exception: {e}\")\n            return False\n\n        # If there is no title, but there is a body, swap the two to get rid\n        # of the weird whitespace\n        if not title:\n            title = body\n            body = \"\"\n\n        # acquire our dbus object\n        dbus_obj = session.get_object(\n            self.dbus_interface,\n            self.dbus_setting_location,\n        )\n\n        # Acquire our dbus interface\n        dbus_iface = Interface(\n            dbus_obj,\n            dbus_interface=self.dbus_interface,\n        )\n\n        # image path\n        icon_path = (\n            None\n            if not self.include_image\n            else self.image_path(notify_type, extension=\".ico\")\n        )\n\n        # Our meta payload\n        meta_payload = {\"urgency\": Byte(self.urgency)}\n\n        if not (self.x_axis is None and self.y_axis is None):\n            # Set x/y access if these were set\n            meta_payload[\"x\"] = self.x_axis\n            meta_payload[\"y\"] = self.y_axis\n\n        if NOTIFY_DBUS_IMAGE_SUPPORT and icon_path:\n            try:\n                # Use Pixbuf to create the proper image type\n                image = GdkPixbuf.Pixbuf.new_from_file(icon_path)\n\n                # Associate our image to our notification\n                meta_payload[\"icon_data\"] = (\n                    image.get_width(),\n                    image.get_height(),\n                    image.get_rowstride(),\n                    image.get_has_alpha(),\n                    image.get_bits_per_sample(),\n                    image.get_n_channels(),\n                    ByteArray(image.get_pixels()),\n                )\n\n            except Exception as e:\n                self.logger.warning(\n                    \"Could not load notification icon (%s).\", icon_path\n                )\n                self.logger.debug(f\"DBus Exception: {e}\")\n\n        try:\n            # Always call throttle() before any remote execution is made\n            self.throttle()\n\n            dbus_iface.Notify(\n                # Application Identifier\n                self.app_id,\n                # Message ID (0 = New Message)\n                0,\n                # Icon (str) - not used\n                \"\",\n                # Title\n                str(title),\n                # Body\n                str(body),\n                # Actions\n                [],\n                # Meta\n                meta_payload,\n                # Message Timeout\n                self.message_timeout_ms,\n            )\n\n            self.logger.info(\"Sent DBus notification.\")\n\n        except Exception as e:\n            self.logger.warning(\"Failed to send DBus notification.\")\n            self.logger.debug(f\"DBus Exception: {e}\")\n            return False\n\n        return True\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"urgency\": (\n                DBUS_URGENCIES[self.template_args[\"urgency\"][\"default\"]]\n                if self.urgency not in DBUS_URGENCIES\n                else DBUS_URGENCIES[self.urgency]\n            ),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # x in (x,y) screen coordinates\n        if self.x_axis:\n            params[\"x\"] = str(self.x_axis)\n\n        # y in (x,y) screen coordinates\n        if self.y_axis:\n            params[\"y\"] = str(self.y_axis)\n\n        return f\"{self.schema}://_/?{NotifyDBus.urlencode(params)}\"\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"There are no parameters nessisary for this protocol; simply having\n        gnome:// is all you need.\n\n        This function just makes sure that is in place.\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # DBus supports urgency, but we we also support the keyword priority\n        # so that it is consistent with some of the other plugins\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            # We intentionally store the priority in the urgency section\n            results[\"urgency\"] = NotifyDBus.unquote(results[\"qsd\"][\"priority\"])\n\n        if \"urgency\" in results[\"qsd\"] and len(results[\"qsd\"][\"urgency\"]):\n            results[\"urgency\"] = NotifyDBus.unquote(results[\"qsd\"][\"urgency\"])\n\n        # handle x,y coordinates\n        if \"x\" in results[\"qsd\"] and len(results[\"qsd\"][\"x\"]):\n            results[\"x_axis\"] = NotifyDBus.unquote(results[\"qsd\"].get(\"x\"))\n\n        if \"y\" in results[\"qsd\"] and len(results[\"qsd\"][\"y\"]):\n            results[\"y_axis\"] = NotifyDBus.unquote(results[\"qsd\"].get(\"y\"))\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/dingtalk.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport base64\nimport hashlib\nimport hmac\nfrom json import dumps\nimport re\nimport time\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Register at https://dingtalk.com\n#   - Download their PC based software as it is the only way you can create\n#     a custom robot.  You can create a custom robot per group.  You will\n#     be provided an access_token that Apprise will need.\n\n# Syntax:\n#  dingtalk://{access_token}/\n#  dingtalk://{access_token}/{optional_phone_no}\n#  dingtalk://{access_token}/{phone_no_1}/{phone_no_2}/{phone_no_N/\n\n# Some Phone Number Detection\nIS_PHONE_NO = re.compile(r\"^\\+?(?P<phone>[0-9\\s)(+-]+)\\s*$\")\n\n\nclass NotifyDingTalk(NotifyBase):\n    \"\"\"A wrapper for DingTalk Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"DingTalk\"\n\n    # The services URL\n    service_url = \"https://www.dingtalk.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"dingtalk\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/dingtalk/\"\n\n    # DingTalk API\n    notify_url = \"https://oapi.dingtalk.com/robot/send?access_token={token}\"\n\n    # Do not set title_maxlen as it is set in a property value below\n    # since the length varies depending if we are doing a markdown\n    # based message or a text based one.\n    # title_maxlen = see below @propery defined\n\n    # Define object templates\n    templates = (\n        \"{schema}://{token}/\",\n        \"{schema}://{token}/{targets}/\",\n        \"{schema}://{secret}@{token}/\",\n        \"{schema}://{secret}@{token}/{targets}/\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"secret\": {\n                \"name\": _(\"Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"target_phone_no\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"secret\": {\n                \"alias_of\": \"secret\",\n            },\n        },\n    )\n\n    def __init__(self, token, targets=None, secret=None, **kwargs):\n        \"\"\"Initialize DingTalk Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Secret Key (associated with project)\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"An invalid DingTalk API Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.secret = None\n        if secret:\n            self.secret = validate_regex(\n                secret, *self.template_tokens[\"secret\"][\"regex\"]\n            )\n            if not self.secret:\n                msg = f\"An invalid DingTalk Secret ({token}) was specified.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_list(targets):\n            # Validate targets and drop bad ones:\n            result = IS_PHONE_NO.match(target)\n            if result:\n                # Further check our phone # for it's digit count\n                result = \"\".join(re.findall(r\"\\d+\", result.group(\"phone\")))\n                if len(result) < 11 or len(result) > 14:\n                    self.logger.warning(\n                        f\"Dropped invalid phone # ({target}) specified.\",\n                    )\n                    continue\n\n                # store valid phone number\n                self.targets.append(result)\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid phone # ({target}) specified.\",\n            )\n\n        return\n\n    def get_signature(self):\n        \"\"\"Calculates time-based signature so that we can send arbitrary\n        messages.\"\"\"\n        timestamp = str(round(time.time() * 1000))\n        secret_enc = self.secret.encode(\"utf-8\")\n        str_to_sign_enc = f\"{timestamp}\\n{self.secret}\".encode()\n        hmac_code = hmac.new(\n            secret_enc, str_to_sign_enc, digestmod=hashlib.sha256\n        ).digest()\n        signature = NotifyDingTalk.quote(base64.b64encode(hmac_code), safe=\"\")\n        return timestamp, signature\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform DingTalk Notification.\"\"\"\n\n        payload = {\n            \"msgtype\": \"text\",\n            \"at\": {\n                \"atMobiles\": self.targets,\n                \"isAtAll\": False,\n            },\n        }\n\n        if self.notify_format == NotifyFormat.MARKDOWN:\n            payload[\"markdown\"] = {\n                \"title\": title,\n                \"text\": body,\n            }\n\n        else:\n            payload[\"text\"] = {\n                \"content\": body,\n            }\n\n        # Our Notification URL\n        notify_url = self.notify_url.format(token=self.token)\n\n        params = None\n        if self.secret:\n            timestamp, signature = self.get_signature()\n            params = {\n                \"timestamp\": timestamp,\n                \"sign\": signature,\n            }\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Some Debug Logging\n        self.logger.debug(\n            \"DingTalk URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"DingTalk Payload: {payload}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=dumps(payload),\n                headers=headers,\n                params=params,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyDingTalk.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send DingTalk notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False\n\n            else:\n                self.logger.info(\"Sent DingTalk notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occured sending DingTalk notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        return True\n\n    @property\n    def title_maxlen(self):\n        \"\"\"The title isn't used when not in markdown mode.\"\"\"\n        return (\n            NotifyBase.title_maxlen\n            if self.notify_format == NotifyFormat.MARKDOWN\n            else 0\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any arguments set\n        args = {\n            \"format\": self.notify_format,\n            \"overflow\": self.overflow_mode,\n            \"verify\": \"yes\" if self.verify_certificate else \"no\",\n        }\n\n        return \"{schema}://{secret}{token}/{targets}/?{args}\".format(\n            schema=self.secure_protocol,\n            secret=(\n                \"\"\n                if not self.secret\n                else \"{}@\".format(\n                    self.pprint(\n                        self.secret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                    )\n                )\n            ),\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyDingTalk.quote(x, safe=\"\") for x in self.targets]\n            ),\n            args=NotifyDingTalk.urlencode(args),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.secret, self.token)\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to\n        substantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        results[\"token\"] = NotifyDingTalk.unquote(results[\"host\"])\n\n        # if a user has been defined, use it's value as the secret\n        if results.get(\"user\"):\n            results[\"secret\"] = results.get(\"user\")\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyDingTalk.split_path(results[\"fullpath\"])\n\n        # Support the use of the `token` keyword argument\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyDingTalk.unquote(results[\"qsd\"][\"token\"])\n\n        # Support the use of the `secret` keyword argument\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            results[\"secret\"] = NotifyDingTalk.unquote(\n                results[\"qsd\"][\"secret\"]\n            )\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyDingTalk.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/discord.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this to work correctly you need to create a webhook. To do this just\n# click on the little gear icon next to the channel you're part of. From\n# here you'll be able to access the Webhooks menu and create a new one.\n#\n#  When you've completed, you'll get a URL that looks a little like this:\n#  https://discord.com/api/webhooks/417429632418316298/\\\n#         JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js\n#\n#  Simplified, it looks like this:\n#     https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n#\n#  This plugin will simply work using the url of:\n#     discord://WEBHOOK_ID/WEBHOOK_TOKEN\n#\n# API Documentation on Webhooks:\n#    - https://discord.com/developers/docs/resources/webhook\n#\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta, timezone\nfrom itertools import chain\nfrom json import dumps\nimport re\nfrom typing import Any\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Used to detect user/role IDs and @here/@everyone tokens.\nUSER_ROLE_DETECTION_RE = re.compile(\n    r\"\\s*(?:<?@(?P<role>&?)(?P<id>[0-9]+)>?|@(?P<value>[a-z0-9]+))\", re.I\n)\n\n\nclass NotifyDiscord(NotifyBase):\n    \"\"\"A wrapper to Discord Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Discord\"\n\n    # The services URL\n    service_url = \"https://discord.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"discord\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/discord/\"\n\n    # Discord Webhook\n    notify_url = \"https://discord.com/api/webhooks\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_256\n\n    # Discord is kind enough to return how many more requests we're allowed to\n    # continue to make within it's header response as:\n    # X-RateLimit-Reset: The epoc time (in seconds) we can expect our\n    #                    rate-limit to be reset.\n    # X-RateLimit-Remaining: an integer identifying how many requests we're\n    #                        still allow to make.\n    request_rate_per_sec = 0\n\n    # Taken right from google.auth.helpers:\n    clock_skew = timedelta(seconds=10)\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 2000\n\n    # The 2000 characters above defined by the body_maxlen include that of the\n    # title.  Setting this to True ensures overflow options behave properly\n    overflow_amalgamate_title = True\n\n    # Discord has a limit of the number of fields you can include in an\n    # embeds message. This value allows the discord message to safely\n    # break into multiple messages to handle these cases.\n    discord_max_fields = 10\n\n    # Define object templates\n    templates = (\n        \"{schema}://{webhook_id}/{webhook_token}\",\n        \"{schema}://{botname}@{webhook_id}/{webhook_token}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"botname\": {\n                \"name\": _(\"Bot Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"user\",\n            },\n            \"webhook_id\": {\n                \"name\": _(\"Webhook ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"webhook_token\": {\n                \"name\": _(\"Webhook Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"tts\": {\n                \"name\": _(\"Text To Speech\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"avatar\": {\n                \"name\": _(\"Avatar Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"avatar_url\": {\n                \"name\": _(\"Avatar URL\"),\n                \"type\": \"string\",\n            },\n            \"href\": {\n                \"name\": _(\"URL\"),\n                \"type\": \"string\",\n            },\n            \"url\": {\n                \"alias_of\": \"href\",\n            },\n            # Send a message to the specified thread within a webhook's\n            # channel. The thread will automatically be unarchived.\n            \"thread\": {\n                \"name\": _(\"Thread ID\"),\n                \"type\": \"string\",\n            },\n            \"footer\": {\n                \"name\": _(\"Display Footer\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"footer_logo\": {\n                \"name\": _(\"Footer Logo\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"fields\": {\n                \"name\": _(\"Use Fields\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"flags\": {\n                \"name\": _(\"Discord Flags\"),\n                \"type\": \"int\",\n                \"min\": 0,\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            # Explicit ping targets. Examples:\n            #  - ping=12345,67890\n            #  - ping=<@12345>,<@&67890>,@here\n            \"ping\": {\n                \"name\": _(\"Ping Users/Roles\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        webhook_id: str,\n        webhook_token: str,\n        tts: bool = False,\n        avatar: bool = True,\n        footer: bool = False,\n        footer_logo: bool = True,\n        include_image: bool = False,\n        fields: bool = True,\n        avatar_url: str | None = None,\n        href: str | None = None,\n        thread: str | None = None,\n        flags: int | None = None,\n        ping: list[str] | None = None,\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"Initialize Discord Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Webhook ID (associated with project)\n        self.webhook_id = validate_regex(webhook_id)\n        if not self.webhook_id:\n            msg = (\n                f\"An invalid Discord Webhook ID ({webhook_id}) was \"\n                \"specified.\")\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Webhook Token (associated with project)\n        self.webhook_token = validate_regex(webhook_token)\n        if not self.webhook_token:\n            msg = (\n                \"An invalid Discord Webhook Token \"\n                f\"({webhook_token}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Text To Speech\n        self.tts = tts\n\n        # Over-ride Avatar Icon\n        self.avatar = avatar\n\n        # Place a footer\n        self.footer = footer\n\n        # include a footer_logo in footer\n        self.footer_logo = footer_logo\n\n        # Place a thumbnail image inline with the message body\n        self.include_image = include_image\n\n        # Use Fields\n        self.fields = fields\n\n        # Specified Thread ID\n        self.thread_id = thread\n\n        # Avatar URL\n        # This allows a user to provide an over-ride to the otherwise\n        # dynamically generated avatar url images\n        self.avatar_url = avatar_url\n\n        # A URL to have the title link to\n        self.href = href\n\n        # A URL to have the title link to\n        if flags:\n            try:\n                self.flags = int(flags)\n                if self.flags < NotifyDiscord.template_args[\"flags\"][\"min\"]:\n                    raise ValueError()\n\n            except (TypeError, ValueError):\n                msg = (\n                    f\"An invalid Discord flags setting ({flags}) was \"\n                    \"specified.\")\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n        else:\n            self.flags = None\n\n        # Ping targets (tokens from URL, already split by parse_list)\n        self.ping: list[str] = parse_list(ping)\n\n        self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)\n\n        # Default to 1.0\n        self.ratelimit_remaining = 1.0\n\n        return\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        attach: list[AttachBase] | None = None,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Perform Discord Notification.\"\"\"\n\n        payload: dict[str, Any] = {\n            \"tts\": self.tts,\n            # If Text-To-Speech is set to True, then we do not want to wait\n            # for the whole message before continuing. Otherwise, we wait\n            \"wait\": self.tts is False,\n        }\n\n        if self.flags:\n            # Set our flag if defined:\n            payload[\"flags\"] = self.flags\n\n        # Acquire image_url\n        image_url = self.image_url(notify_type)\n\n        if self.avatar and (image_url or self.avatar_url):\n            payload[\"avatar_url\"] = (\n                self.avatar_url if self.avatar_url else image_url\n            )\n\n        if self.user:\n            # Optionally override the default username of the webhook\n            payload[\"username\"] = self.user\n\n        # Associate our thread_id with our message\n        params = {\"thread_id\": self.thread_id} if self.thread_id else None\n\n        # Ping handling rules:\n        # - If ping= is set, it is an additive if in MARKDOWN mode otherwise\n        #   it is explicit for TEXT/HTML formats.\n        # - Otherwise, ping detection only happens in MARKDOWN mode\n        if self.notify_format == NotifyFormat.MARKDOWN:\n            if self.ping:\n                payload.update(self.ping_payload(body, \" \".join(self.ping)))\n            else:\n                payload.update(self.ping_payload(body))\n\n        # TEXT/HTML: no body parsing, ping= is exclusive\n        elif self.ping:\n            payload.update(self.ping_payload(\" \".join(self.ping)))\n\n        if body:\n            # Track extra embed fields (if used)\n            fields: list[dict[str, str]] = []\n\n            if self.notify_format == NotifyFormat.MARKDOWN:\n                # Use embeds for payload\n                payload[\"embeds\"] = [{\n                    \"author\": {\n                        \"name\": self.app_id,\n                        \"url\": self.app_url,\n                    },\n                    \"title\": title,\n                    \"description\": body,\n                    # Our color associated with our notification\n                    \"color\": self.color(notify_type, int),\n                }]\n\n                if self.href:\n                    payload[\"embeds\"][0][\"url\"] = self.href\n\n                if self.footer:\n                    # Acquire logo URL\n                    logo_url = self.image_url(notify_type, logo=True)\n\n                    # Set Footer text to our app description\n                    payload[\"embeds\"][0][\"footer\"] = {\n                        \"text\": self.app_desc,\n                    }\n\n                    if self.footer_logo and logo_url:\n                        payload[\"embeds\"][0][\"footer\"][\"icon_url\"] = logo_url\n\n                if self.include_image and image_url:\n                    payload[\"embeds\"][0][\"thumbnail\"] = {\n                        \"url\": image_url,\n                        \"height\": 256,\n                        \"width\": 256,\n                    }\n\n                if self.fields:\n                    # Break titles out so that we can sort them in embeds\n                    description, fields = self.extract_markdown_sections(body)\n\n                    # Swap first entry for description\n                    payload[\"embeds\"][0][\"description\"] = description\n                    if fields:\n                        # Apply our additional parsing for a better\n                        # presentation\n                        payload[\"embeds\"][0][\"fields\"] = fields[\n                            : self.discord_max_fields\n                        ]\n                        fields = fields[self.discord_max_fields :]\n            else:\n                # TEXT or HTML:\n                # - No ping detection unless ping= was provided.\n                # - If ping= was provided, ping_payload() already generated\n                #   payload[\"content\"] starting with \"👉 ...\", and we append\n                #   it.\n                payload[\"content\"] = (\n                    body if not title else f\"{title}\\r\\n{body}\"\n                ) + payload.get(\"content\", \"\")\n\n            if not self._send(payload, params=params):\n                # We failed to post our message\n                return False\n\n            # Send remaining fields (if any)\n            if fields:\n                payload[\"embeds\"][0][\"description\"] = \"\"\n                for i in range(0, len(fields), self.discord_max_fields):\n                    payload[\"embeds\"][0][\"fields\"] = fields[\n                        i : i + self.discord_max_fields\n                    ]\n                    if not self._send(payload):\n                        # We failed to post our message\n                        return False\n\n        if attach and self.attachment_support:\n            # Update our payload; the idea is to preserve it's other detected\n            # and assigned values for re-use here too\n            payload.update({\n                # Text-To-Speech\n                \"tts\": False,\n                # Wait until the upload has posted itself before continuing\n                \"wait\": True,\n            })\n\n            #\n            # Remove our text/title based content for attachment use\n            #\n            payload.pop(\"embeds\", None)\n            payload.pop(\"content\", None)\n            payload.pop(\"allow_mentions\", None)\n\n            #\n            # Send our attachments\n            #\n            for attachment in attach:\n                self.logger.info(\n                    f\"Posting Discord Attachment {attachment.name}\"\n                )\n                if not self._send(payload, params=params, attach=attachment):\n                    # We failed to post our message\n                    return False\n\n        # Otherwise return\n        return True\n\n    def _send(\n        self,\n        payload: dict[str, Any],\n        attach: AttachBase | None = None,\n        params: dict[str, str] | None = None,\n        rate_limit: int = 1,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n\n        # Our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Construct Notify URL\n        notify_url = (\n            f\"{self.notify_url}/{self.webhook_id}/{self.webhook_token}\"\n        )\n\n        self.logger.debug(\n            \"Discord POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Discord Payload: {payload!s}\")\n\n        wait: float | None = None\n\n        if self.ratelimit_remaining <= 0.0:\n            # Determine how long we should wait for or if we should wait at\n            # all. This isn't fool-proof because we can't be sure the client\n            # time (calling this script) is completely synced up with the\n            # Discord server.  One would hope we're on NTP and our clocks are\n            # the same allowing this to role smoothly:\n\n            now = datetime.now(timezone.utc).replace(tzinfo=None)\n            if now < self.ratelimit_reset:\n                # We need to throttle for the difference in seconds\n                wait = abs(\n                    (\n                        self.ratelimit_reset - now + self.clock_skew\n                    ).total_seconds()\n                )\n\n        # Always call throttle before any remote server i/o is made;\n        self.throttle(wait=wait)\n\n        # Perform some simple error checking\n        if isinstance(attach, AttachBase):\n            if not attach:\n                # We could not access the attachment\n                self.logger.error(\n                    f\"Could not access attachment {attach.url(privacy=True)}.\"\n                )\n                return False\n\n            self.logger.debug(\n                f\"Posting Discord attachment {attach.url(privacy=True)}\"\n            )\n\n        # Our attachment path (if specified)\n        files = None\n        try:\n\n            # Open our attachment path if required:\n            if attach:\n                files = {\n                    \"file\": (\n                        attach.name,\n                        # file handle is safely closed in `finally`; inline\n                        # open is intentional\n                        open(attach.path, \"rb\"),  # noqa: SIM115\n                    )\n                }\n            else:\n                headers[\"Content-Type\"] = \"application/json; charset=utf-8\"\n\n            r = requests.post(\n                notify_url,\n                params=params,\n                data=payload if files else dumps(payload),\n                headers=headers,\n                files=files,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            # Handle rate limiting (if specified)\n            try:\n                # Store our rate limiting (if provided)\n                self.ratelimit_remaining = float(\n                    r.headers.get(\"X-RateLimit-Remaining\")\n                )\n                self.ratelimit_reset = datetime.fromtimestamp(\n                    int(r.headers.get(\"X-RateLimit-Reset\")), timezone.utc\n                ).replace(tzinfo=None)\n\n            except (TypeError, ValueError):\n                # This is returned if we could not retrieve this\n                # information gracefully accept this state and move on\n                pass\n\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n\n                # We had a problem\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code\n                )\n\n                if (\n                    r.status_code == requests.codes.too_many_requests\n                    and rate_limit > 0\n                ):\n\n                    # handle rate limiting\n                    self.logger.warning(\n                        \"Discord rate limiting in effect; \"\n                        \"blocking for %.2f second(s)\",\n                        self.ratelimit_remaining,\n                    )\n\n                    # Try one more time before failing\n                    return self._send(\n                        payload=payload,\n                        attach=attach,\n                        params=params,\n                        rate_limit=rate_limit - 1,\n                        **kwargs,\n                    )\n\n                self.logger.warning(\n                    \"Failed to send {}to Discord notification: \"\n                    \"{}{}error={}.\".format(\n                        attach.name if attach else \"\",\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\n                    \"Sent Discord {}.\".format(\n                        \"attachment\" if attach else \"notification\"\n                    )\n                )\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred posting {}to Discord.\".format(\n                    attach.name if attach else \"\"\n                )\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while reading {}.\".format(\n                    attach.name if attach else \"attachment\"\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return False\n\n        finally:\n            # Close our file (if it's open) stored in the second element\n            # of our files tuple (index 1)\n            if files:\n                files[\"file\"][1].close()\n\n        return True\n\n    def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        params: dict[str, str] = {\n            \"tts\": \"yes\" if self.tts else \"no\",\n            \"avatar\": \"yes\" if self.avatar else \"no\",\n            \"footer\": \"yes\" if self.footer else \"no\",\n            \"footer_logo\": \"yes\" if self.footer_logo else \"no\",\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"fields\": \"yes\" if self.fields else \"no\",\n        }\n\n        if self.avatar_url:\n            params[\"avatar_url\"] = self.avatar_url\n\n        if self.flags:\n            params[\"flags\"] = str(self.flags)\n\n        if self.href:\n            params[\"href\"] = self.href\n\n        if self.thread_id:\n            params[\"thread\"] = self.thread_id\n\n        if self.ping:\n            # Let Apprise urlencode handle list formatting\n            params[\"ping\"] = \",\".join(self.ping)\n\n        # Ensure our botname is set\n        botname = f\"{self.user}@\" if self.user else \"\"\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return (\n            \"{schema}://{bname}{webhook_id}/{webhook_token}/?{params}\".format(\n                schema=self.secure_protocol,\n                bname=botname,\n                webhook_id=self.pprint(self.webhook_id, privacy, safe=\"\"),\n                webhook_token=self.pprint(\n                    self.webhook_token, privacy, safe=\"\"),\n                params=NotifyDiscord.urlencode(params),\n            )\n        )\n\n    @property\n    def url_identifier(self) -> tuple[str, str, str]:\n        \"\"\"Returns all of the identifiers that make this URL unique.\"\"\"\n        return (self.secure_protocol, self.webhook_id, self.webhook_token)\n\n    @staticmethod\n    def parse_url(url: str) -> dict[str, Any] | None:\n        \"\"\"Parses the URL and returns arguments for instantiating this object.\n\n        Syntax:\n          discord://webhook_id/webhook_token\n        \"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Store our webhook ID\n        webhook_id = NotifyDiscord.unquote(results[\"host\"])\n\n        # Now fetch our tokens\n        try:\n            webhook_token = NotifyDiscord.split_path(results[\"fullpath\"])[0]\n\n        except IndexError:\n            # Force some bad values that will get caught\n            # in parsing later\n            webhook_token = None\n\n        results[\"webhook_id\"] = webhook_id\n        results[\"webhook_token\"] = webhook_token\n\n        # Text To Speech\n        results[\"tts\"] = parse_bool(results[\"qsd\"].get(\"tts\", False))\n\n        # Use sections\n        # effectively detect multiple fields and break them off\n        # into sections\n        results[\"fields\"] = parse_bool(results[\"qsd\"].get(\"fields\", True))\n\n        # Use Footer\n        results[\"footer\"] = parse_bool(results[\"qsd\"].get(\"footer\", False))\n\n        # Use Footer Logo\n        results[\"footer_logo\"] = parse_bool(\n            results[\"qsd\"].get(\"footer_logo\", True)\n        )\n\n        # Update Avatar Icon\n        results[\"avatar\"] = parse_bool(results[\"qsd\"].get(\"avatar\", True))\n\n        # Boolean to include an image or not\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyDiscord.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        if \"botname\" in results[\"qsd\"]:\n            # Alias to User\n            results[\"user\"] = NotifyDiscord.unquote(results[\"qsd\"][\"botname\"])\n\n        if \"flags\" in results[\"qsd\"]:\n            # Alias to User\n            results[\"flags\"] = NotifyDiscord.unquote(results[\"qsd\"][\"flags\"])\n\n        # Extract avatar url if it was specified\n        if \"avatar_url\" in results[\"qsd\"]:\n            results[\"avatar_url\"] = NotifyDiscord.unquote(\n                results[\"qsd\"][\"avatar_url\"]\n            )\n\n        # Extract url if it was specified\n        if \"href\" in results[\"qsd\"]:\n            results[\"href\"] = NotifyDiscord.unquote(results[\"qsd\"][\"href\"])\n\n        elif \"url\" in results[\"qsd\"]:\n            results[\"href\"] = NotifyDiscord.unquote(results[\"qsd\"][\"url\"])\n            # Markdown is implied\n            results[\"format\"] = NotifyFormat.MARKDOWN\n\n        # Extract thread id if it was specified\n        if \"thread\" in results[\"qsd\"]:\n            results[\"thread\"] = NotifyDiscord.unquote(results[\"qsd\"][\"thread\"])\n            # Markdown is implied\n            results[\"format\"] = NotifyFormat.MARKDOWN\n\n        # Extract ping targets, comma/space separated\n        if \"ping\" in results[\"qsd\"]:\n            results[\"ping\"] = NotifyDiscord.unquote(results[\"qsd\"][\"ping\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url: str) -> dict[str, Any] | None:\n        \"\"\"\n        Support https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n        Support Legacy URL as well:\n            https://discordapp.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://discord(app)?\\.com/api/webhooks/\"\n            r\"(?P<webhook_id>[0-9]+)/\"\n            r\"(?P<webhook_token>[A-Z0-9_-]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyDiscord.parse_url(\n                \"{schema}://{webhook_id}/{webhook_token}/{params}\".format(\n                    schema=NotifyDiscord.secure_protocol,\n                    webhook_id=result.group(\"webhook_id\"),\n                    webhook_token=result.group(\"webhook_token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n\n    def ping_payload(self, *args: str) -> dict[str, Any]:\n        \"\"\"\n        Takes one or more strings and applies the payload associated with\n        pinging the users detected within.\n\n        This returns a dict that may contain:\n          - allow_mentions\n          - content (starting with \"👉 \" and containing mention tokens)\n        \"\"\"\n\n        payload: dict[str, Any] = {}\n\n        roles: set[str] = set()\n        users: set[str] = set()\n        parse: set[str] = set()\n\n        for arg in args:\n            # parse for user id's <@123> and role IDs <@&456>\n            results = USER_ROLE_DETECTION_RE.findall(arg)\n            if not results:\n                continue\n\n            for is_role, no, value in results:\n                if value:\n                    parse.add(value)\n\n                elif is_role:\n                    roles.add(no)\n\n                else:  # is_user\n                    users.add(no)\n\n        if not (roles or users or parse):\n            # Nothing to add\n            return payload\n\n        payload[\"allow_mentions\"] = {\n            \"parse\": list(parse),\n            \"users\": list(users),\n            \"roles\": list(roles),\n        }\n\n        payload[\"content\"] = \"👉 \" + \" \".join(\n            chain(\n                [f\"@{value}\" for value in parse],\n                [f\"<@&{value}>\" for value in roles],\n                [f\"<@{value}>\" for value in users],\n            )\n        )\n\n        return payload\n\n    @staticmethod\n    def extract_markdown_sections(\n            markdown: str) -> tuple[str, list[dict[str, str]]]:\n        \"\"\"Extract headers and their corresponding sections into embed\n        fields.\"\"\"\n\n        # Search for any header information found without it's own section\n        # identifier\n        match = re.match(\n            r\"^\\s*(?P<desc>[^\\s#]+.*?)(?=\\s*$|[\\r\\n]+\\s*#)\",\n            markdown,\n            flags=re.S,\n        )\n\n        description = match.group(\"desc\").strip() if match else \"\"\n        if description:\n            # Strip description from our string since it has been handled\n            # now.\n            markdown = re.sub(re.escape(description), \"\", markdown, count=1)\n\n        regex = re.compile(\n            r\"\\s*#[# \\t\\v]*(?P<name>[^\\n]+)(\\n|\\s*$)\"\n            r\"\\s*((?P<value>[^#].+?)(?=\\s*$|[\\r\\n]+\\s*#))?\",\n            flags=re.S,\n        )\n\n        common = regex.finditer(markdown)\n        fields: list[dict[str, str]] = []\n        for el in common:\n            d = el.groupdict()\n\n            fields.append({\n                \"name\": d.get(\"name\", \"\").strip(\"#`* \\r\\n\\t\\v\"),\n                \"value\": \"```{}\\n{}```\".format(\n                    \"md\" if d.get(\"value\") else \"\",\n                    (d.get(\"value\").strip() + \"\\n\" if d.get(\"value\") else \"\"),\n                ),\n            })\n\n        return description, fields\n"
  },
  {
    "path": "apprise/plugins/dot.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n#\n# API: https://dot.mindreset.tech/docs/service/studio/api/text_api\n#      https://dot.mindreset.tech/docs/service/studio/api/image_api\n#\n# Text API Fields:\n#   - refreshNow (bool, optional, default true): controls display timing.\n#   - deviceId (string, required): unique device serial.\n#   - title (string, optional): title text shown on screen.\n#   - message (string, optional): body text shown on screen.\n#   - signature (string, optional): footer/signature text.\n#   - icon (string, optional): base64 PNG icon (40px x 40px).\n#   - link (string, optional): tap-to-interact target URL.\n#\n# Image API Fields:\n#   - refreshNow (bool, optional, default true): controls display timing.\n#   - deviceId (string, required): unique device serial.\n#   - image (string, required): base64 PNG image (296px x 152px).\n#   - link (string, optional): tap-to-interact target URL.\n#   - border (number, optional, default 0): 0=white, 1=black frame.\n#   - ditherType (string, optional, default DIFFUSION): dithering mode.\n#   - ditherKernel (string, optional, default FLOYD_STEINBERG):\n#     dithering kernel.\n\nfrom contextlib import suppress\nimport json\nimport logging\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n# Supported Dither Types\nDOT_DITHER_TYPES = (\n    \"DIFFUSION\",\n    \"ORDERED\",\n    \"NONE\",\n)\n\n# Supported Dither Kernels\nDOT_DITHER_KERNELS = (\n    \"THRESHOLD\",\n    \"ATKINSON\",\n    \"BURKES\",\n    \"FLOYD_STEINBERG\",\n    \"SIERRA2\",\n    \"STUCKI\",\n    \"JARVIS_JUDICE_NINKE\",\n    \"DIFFUSION_ROW\",\n    \"DIFFUSION_COLUMN\",\n    \"DIFFUSION_2D\",\n)\n\n\nclass NotifyDot(NotifyBase):\n    \"\"\"A wrapper for Dot. Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Dot.\"\n    # Alias: devices marketed as \"Quote/0\" remain discoverable.\n\n    # The services URL\n    service_url = \"https://dot.mindreset.tech\"\n\n    # All notification requests are secure\n    secure_protocol = \"dot\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/dot/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # Support Attachments\n    attachment_support = True\n\n    # Supported API modes\n    SUPPORTED_MODES = (\"text\", \"image\")\n\n    DEFAULT_MODE = \"text\"\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}@{device_id}/{mode}/\",)\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n            },\n            \"device_id\": {\n                \"name\": _(\"Device Serial Number\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"map_to\": \"device_id\",\n            },\n            \"mode\": {\n                \"name\": _(\"API Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": SUPPORTED_MODES,\n                \"default\": DEFAULT_MODE,\n                \"required\": True,\n                \"map_to\": \"mode\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"refresh\": {\n                \"name\": _(\"Refresh Now\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"refresh_now\",\n            },\n            \"signature\": {\n                \"name\": _(\"Text Signature\"),\n                \"type\": \"string\",\n            },\n            \"icon\": {\n                \"name\": _(\"Icon Base64 (Text API)\"),\n                \"type\": \"string\",\n            },\n            \"image\": {\n                \"name\": _(\"Image Base64 (Image API)\"),\n                \"type\": \"string\",\n                \"map_to\": \"image_data\",\n            },\n            \"link\": {\n                \"name\": _(\"Link\"),\n                \"type\": \"string\",\n            },\n            \"border\": {\n                \"name\": _(\"Border\"),\n                \"type\": \"int\",\n                \"min\": 0,\n                \"max\": 1,\n                \"default\": 0,\n            },\n            \"dither_type\": {\n                \"name\": _(\"Dither Type\"),\n                \"type\": \"choice:string\",\n                \"values\": DOT_DITHER_TYPES,\n                \"default\": \"DIFFUSION\",\n            },\n            \"dither_kernel\": {\n                \"name\": _(\"Dither Kernel\"),\n                \"type\": \"choice:string\",\n                \"values\": DOT_DITHER_KERNELS,\n                \"default\": \"FLOYD_STEINBERG\",\n            },\n        },\n    )\n    # Note:\n    # - icon (Text API): base64 PNG icon (40px x 40px) in lower-left corner.\n    #   Can be provided via `icon` parameter or first attachment.\n    # - image (Image API): base64 PNG image (296px x 152px) supplied via\n    #   configuration `image` parameter or first attachment.\n    # - Only the first attachment is used; multiple attachments trigger a\n    #   warning.\n\n    def __init__(\n        self,\n        apikey=None,\n        device_id=None,\n        mode=DEFAULT_MODE,\n        refresh_now=True,\n        signature=None,\n        icon=None,\n        link=None,\n        border=None,\n        dither_type=None,\n        dither_kernel=None,\n        image_data=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify Dot Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (from user)\n        self.apikey = apikey\n\n        # Device ID tracks the Dot hardware serial.\n        self.device_id = device_id\n\n        # Refresh Now flag: True shows content immediately (default).\n        self.refresh_now = parse_bool(refresh_now, default=True)\n\n        # API mode (\"text\" or \"image\")\n        self.mode = (\n            mode.lower()\n            if isinstance(mode, str) and mode.lower() in self.SUPPORTED_MODES\n            else self.DEFAULT_MODE\n        )\n        if (\n            not isinstance(mode, str)\n            or mode.lower() not in self.SUPPORTED_MODES\n        ):\n            self.logger.warning(\n                \"Unsupported Dot mode (%s) specified; defaulting to '%s'.\",\n                mode,\n                self.mode,\n            )\n\n        # Signature text used by the Text API footer.\n        self.signature = signature if isinstance(signature, str) else None\n\n        # Icon for the Text API (base64 PNG 40x40, lower-left corner).\n        # Note: distinct from the Image API \"image\" field.\n        self.icon = icon if isinstance(icon, str) else None\n\n        # Image payload for the Image API (base64 PNG 296x152).\n        self.image_data = image_data if isinstance(image_data, str) else None\n        if self.mode == \"text\" and self.image_data:\n            self.logger.warning(\n                \"Image data provided in text mode; ignoring configurable\"\n                \" image payload.\"\n            )\n            self.image_data = None\n\n        # Link for tap-to-interact navigation.\n        self.link = link if isinstance(link, str) else None\n\n        # Border for the Image API\n        self.border = border\n\n        # Dither type for Image API\n        self.dither_type = dither_type\n\n        # Dither kernel for the Image API\n        self.dither_kernel = dither_kernel\n\n        # Text API endpoint\n        self.text_api_url = \"https://dot.mindreset.tech/api/open/text\"\n\n        # Image API endpoint\n        self.image_api_url = \"https://dot.mindreset.tech/api/open/image\"\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Dot Notification.\"\"\"\n\n        if not self.apikey:\n            self.logger.warning(\"No API key was specified\")\n            return False\n\n        if not self.device_id:\n            self.logger.warning(\"No device ID was specified\")\n            return False\n\n        # Prepare our headers\n        headers = {\n            \"Authorization\": f\"Bearer {self.apikey}\",\n            \"Content-Type\": \"application/json\",\n            \"User-Agent\": self.app_id,\n        }\n\n        if self.mode == \"image\":\n            if title or body:\n                self.logger.warning(\n                    \"Title and body are not supported in image mode \"\n                    \"and will be ignored.\"\n                )\n\n            image_data = (\n                self.image_data if isinstance(self.image_data, str) else None\n            )\n\n            # Use first attachment as image if no image_data provided\n            # attachment.base64() returns base64-encoded string for API\n            if not image_data and attach and self.attachment_support:\n                if len(attach) > 1:\n                    self.logger.warning(\n                        \"Multiple attachments provided; only the first \"\n                        \"one will be used as image.\"\n                    )\n                try:\n                    attachment = attach[0]\n                    if attachment:\n                        # Convert attachment to base64-encoded string\n                        image_data = attachment.base64()\n                except Exception as e:\n                    self.logger.warning(f\"Failed to process attachment: {e!s}\")\n\n            if not image_data:\n                self.logger.warning(\n                    \"Image API mode selected but no image data was provided.\"\n                )\n                return False\n\n            # Use Image API\n            # Image API payload:\n            #   refreshNow: display timing control.\n            #   deviceId: Dot device serial (required).\n            #   image: base64 PNG 296x152 (required).\n            #   link: optional tap target.\n            #   border: optional frame color.\n            #   ditherType: optional dithering mode.\n            #   ditherKernel: optional dithering kernel.\n            payload = {\n                \"refreshNow\": self.refresh_now,\n                \"deviceId\": self.device_id,\n                \"image\": image_data,  # Image payload shown on screen\n            }\n\n            if self.link:\n                payload[\"link\"] = self.link\n\n            if self.border is not None:\n                payload[\"border\"] = self.border\n\n            if self.dither_type is not None:\n                payload[\"ditherType\"] = self.dither_type\n\n            if self.dither_kernel is not None:\n                payload[\"ditherKernel\"] = self.dither_kernel\n\n            api_url = self.image_api_url\n\n        else:\n            # Use Text API\n            # Text API payload:\n            #   refreshNow: display timing control.\n            #   deviceId: Dot device serial (required).\n            #   title: optional title on screen.\n            #   message: optional body on screen.\n            #   signature: optional footer text.\n            #   icon: optional base64 PNG icon (40x40).\n            #   link: optional tap target.\n            payload = {\n                \"refreshNow\": self.refresh_now,\n                \"deviceId\": self.device_id,\n            }\n\n            if title:\n                payload[\"title\"] = title\n\n            if body:\n                payload[\"message\"] = body\n\n            if self.signature:\n                payload[\"signature\"] = (\n                    self.signature\n                )  # Footer/signature displayed on screen\n\n            # Use first attachment as icon if no icon provided\n            # attachment.base64() returns base64-encoded string for API\n            icon_data = self.icon\n            if not icon_data and attach and self.attachment_support:\n                if len(attach) > 1:\n                    self.logger.warning(\n                        \"Multiple attachments provided; only the first \"\n                        \"one will be used as icon.\"\n                    )\n                try:\n                    attachment = attach[0]\n                    if attachment:\n                        # Convert attachment to base64-encoded string\n                        icon_data = attachment.base64()\n                except Exception as e:\n                    self.logger.warning(f\"Failed to process attachment: {e!s}\")\n\n            if icon_data:\n                # Text API icon payload\n                payload[\"icon\"] = icon_data\n\n            if self.link:\n                payload[\"link\"] = self.link\n\n            api_url = self.text_api_url\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                \"Dot POST URL:\"\n                f\" {api_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(\"Dot Payload %s\", sanitize_payload(payload))\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                api_url,\n                data=json.dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code == requests.codes.ok:\n                self.logger.info(f\"Sent Dot notification to {self.device_id}.\")\n                return True\n\n            # We had a problem\n            status_str = NotifyDot.http_response_code_lookup(r.status_code)\n\n            self.logger.warning(\n                \"Failed to send Dot notification to {}: \"\n                \"{}{}error={}.\".format(\n                    self.device_id,\n                    status_str,\n                    \", \" if status_str else \"\",\n                    r.status_code,\n                )\n            )\n\n            self.logger.debug(\n                \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n            return False\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Dot \"\n                f\"notification to {self.device_id}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another similar one.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.apikey,\n            self.device_id,\n            self.mode,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"refresh\": \"yes\" if self.refresh_now else \"no\",\n        }\n\n        if self.mode == \"text\":\n            if self.signature:\n                params[\"signature\"] = self.signature\n\n            if self.icon:\n                params[\"icon\"] = self.icon\n\n            if self.link:\n                params[\"link\"] = self.link\n\n        else:  # image mode\n            if self.image_data:\n                params[\"image\"] = self.image_data\n\n            if self.link:\n                params[\"link\"] = self.link\n\n            if self.border is not None:\n                params[\"border\"] = str(self.border)\n\n            if self.dither_type and self.dither_type != \"DIFFUSION\":\n                params[\"dither_type\"] = self.dither_type\n\n            if self.dither_kernel and self.dither_kernel != \"FLOYD_STEINBERG\":\n                params[\"dither_kernel\"] = self.dither_kernel\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        mode_segment = f\"/{self.mode}/\"\n\n        return \"{schema}://{apikey}@{device_id}{mode}?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(\n                self.apikey, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            device_id=NotifyDot.quote(self.device_id, safe=\"\"),\n            mode=mode_segment,\n            params=NotifyDot.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return 1 if self.device_id else 0\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Determine API mode from path (default to text)\n        mode = NotifyDot.DEFAULT_MODE\n        path_tokens = NotifyDot.split_path(results.get(\"fullpath\"))\n        if path_tokens:\n            candidate = path_tokens.pop(0).lower()\n            if candidate in NotifyDot.SUPPORTED_MODES:\n                mode = candidate\n            else:\n                NotifyDot.logger.warning(\n                    \"Unsupported Dot mode (%s) detected; defaulting to '%s'.\",\n                    candidate,\n                    NotifyDot.DEFAULT_MODE,\n                )\n        results[\"mode\"] = mode\n        remaining_path = \"/\".join(path_tokens)\n        results[\"fullpath\"] = \"/\" + remaining_path if remaining_path else \"/\"\n        results[\"path\"] = remaining_path\n\n        # Extract API key from user\n        user = results.get(\"user\")\n        if user:\n            results[\"apikey\"] = NotifyDot.unquote(user)\n\n        # Extract device ID from hostname\n        host = results.get(\"host\")\n        if host:\n            results[\"device_id\"] = NotifyDot.unquote(host)\n\n        # Refresh Now\n        refresh_value = results[\"qsd\"].get(\"refresh\")\n        if refresh_value:\n            results[\"refresh_now\"] = parse_bool(refresh_value.strip())\n\n        # Signature\n        signature_value = results[\"qsd\"].get(\"signature\")\n        if signature_value:\n            results[\"signature\"] = NotifyDot.unquote(signature_value.strip())\n\n        # Icon\n        icon_value = results[\"qsd\"].get(\"icon\")\n        if icon_value:\n            results[\"icon\"] = NotifyDot.unquote(icon_value.strip())\n\n        # Link\n        link_value = results[\"qsd\"].get(\"link\")\n        if link_value:\n            results[\"link\"] = NotifyDot.unquote(link_value.strip())\n\n        # Border\n        border_value = results[\"qsd\"].get(\"border\")\n        if border_value:\n            with suppress(TypeError, ValueError):\n                results[\"border\"] = int(border_value.strip())\n\n        # Dither Type\n        dither_type_value = results[\"qsd\"].get(\"dither_type\")\n        if dither_type_value:\n            results[\"dither_type\"] = NotifyDot.unquote(\n                dither_type_value.strip()\n            )\n\n        # Dither Kernel\n        dither_kernel_value = results[\"qsd\"].get(\"dither_kernel\")\n        if dither_kernel_value:\n            results[\"dither_kernel\"] = NotifyDot.unquote(\n                dither_kernel_value.strip()\n            )\n\n        # Image (Image API)\n        image_value = results[\"qsd\"].get(\"image\")\n        if image_value:\n            results[\"image_data\"] = NotifyDot.unquote(image_value.strip())\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/email/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom email import charset\n\nfrom .base import NotifyEmail\nfrom .common import (\n    SECURE_MODES,\n    AppriseEmailException,\n    EmailMessage,\n    SecureMailMode,\n    WebBaseLogin,\n)\nfrom .templates import EMAIL_TEMPLATES\n\n# Globally Default encoding mode set to Quoted Printable.\ncharset.add_charset(\"utf-8\", charset.QP, charset.QP, \"utf-8\")\n\n__all__ = [\n    \"EMAIL_TEMPLATES\",\n    \"SECURE_MODES\",\n    \"AppriseEmailException\",\n    \"EmailMessage\",\n    \"NotifyEmail\",\n    \"SecureMailMode\",\n    \"WebBaseLogin\",\n]\n"
  },
  {
    "path": "apprise/plugins/email/base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime\nfrom email.header import Header\nfrom email.mime.application import MIMEApplication\nfrom email.mime.base import MIMEBase\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\nfrom email.utils import format_datetime, formataddr, make_msgid\nimport re\nimport smtplib\nfrom typing import Optional\n\nfrom ...common import NotifyFormat, NotifyType, PersistentStoreMode\nfrom ...conversion import convert_between\nfrom ...locale import gettext_lazy as _\nfrom ...logger import logger\nfrom ...url import PrivacyMode\nfrom ...utils import pgp as _pgp\nfrom ...utils.parse import (\n    is_email,\n    is_hostname,\n    is_ipaddr,\n    parse_bool,\n    parse_emails,\n)\nfrom ..base import NotifyBase\nfrom . import templates\nfrom .common import (\n    SECURE_MODES,\n    AppriseEmailException,\n    EmailMessage,\n    SecureMailMode,\n    WebBaseLogin,\n)\n\n\nclass NotifyEmail(NotifyBase):\n    \"\"\"\n    A wrapper to Email Notifications\n\n    \"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"E-Mail\"\n\n    # The default simple (insecure) protocol\n    protocol = \"mailto\"\n\n    # The default secure protocol\n    secure_protocol = \"mailtos\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/email/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference; this allows us to auto-generate our config if needed\n    storage_mode = PersistentStoreMode.AUTO\n\n    # Default Notify Format\n    notify_format = NotifyFormat.HTML\n\n    # Default SMTP Timeout (in seconds)\n    socket_connect_timeout = 15\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}/{targets}\",\n        \"{schema}://{user}@{host}\",\n        \"{schema}://{user}@{host}/{targets}\",\n        \"{schema}://{user}@{host}:{port}\",\n        \"{schema}://{user}@{host}/{targets}\",\n        \"{schema}://{user}@{host}:{port}/{targets}\",\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"host\": {\n                \"name\": _(\"Domain\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"name\": _(\"From Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"from_addr\",\n            },\n            \"name\": {\n                \"name\": _(\"From Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"from_addr\",\n            },\n            \"smtp\": {\n                \"name\": _(\"SMTP Server\"),\n                \"type\": \"string\",\n                \"map_to\": \"smtp_host\",\n            },\n            \"mode\": {\n                \"name\": _(\"Secure Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": SECURE_MODES,\n                \"default\": SecureMailMode.STARTTLS,\n                \"map_to\": \"secure_mode\",\n            },\n            \"reply\": {\n                \"name\": _(\"Reply To\"),\n                \"type\": \"list:string\",\n                \"map_to\": \"reply_to\",\n            },\n            \"pgp\": {\n                \"name\": _(\"PGP Encryption\"),\n                \"type\": \"bool\",\n                \"map_to\": \"use_pgp\",\n                \"default\": False,\n            },\n            \"pgpkey\": {\n                \"name\": _(\"PGP Public Key Path\"),\n                \"type\": \"string\",\n                \"private\": True,\n                # By default persistent storage is referenced\n                \"default\": \"\",\n                \"map_to\": \"pgp_key\",\n            },\n            \"to\": {\n                \"name\": _(\"To Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"Email Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self,\n        smtp_host=None,\n        from_addr=None,\n        secure_mode=None,\n        targets=None,\n        cc=None,\n        bcc=None,\n        reply_to=None,\n        headers=None,\n        use_pgp=None,\n        pgp_key=None,\n        **kwargs,\n    ):\n        \"\"\"\n        Initialize Email Object\n\n        The smtp_host and secure_mode can be automatically detected depending\n        on how the URL was built\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # Acquire Email 'To'\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # Acquire Reply To\n        self.reply_to = set()\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        # Now we want to construct the To and From email\n        # addresses from the URL provided\n        self.from_addr = [False, \"\"]\n\n        # Now detect the SMTP Server\n        self.smtp_host = smtp_host if isinstance(smtp_host, str) else \"\"\n\n        # Now detect secure mode\n        if secure_mode:\n            self.secure_mode = (\n                None\n                if not isinstance(secure_mode, str)\n                else secure_mode.lower()\n            )\n        else:\n            self.secure_mode = (\n                SecureMailMode.INSECURE\n                if not self.secure\n                else self.template_args[\"mode\"][\"default\"]\n            )\n\n        if self.secure_mode not in SECURE_MODES:\n            msg = \"The secure mode specified ({}) is invalid.\".format(\n                secure_mode\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n            email = is_email(recipient)\n            if email:\n                self.cc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Carbon Copy email ({}) specified.\".format(\n                    recipient\n                ),\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n            email = is_email(recipient)\n            if email:\n                self.bcc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                \"({}) specified.\".format(recipient),\n            )\n\n        # Validate recipients (reply-to:) and drop bad ones:\n        for recipient in parse_emails(reply_to):\n            email = is_email(recipient)\n            if email:\n                self.reply_to.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Reply To email ({}) specified.\".format(\n                    recipient\n                ),\n            )\n\n        # Apply any defaults based on certain known configurations\n        self.apply_email_defaults(secure_mode=secure_mode, **kwargs)\n\n        if self.user:\n            if self.host:\n                # Prepare the bases of our email\n                self.from_addr = [\n                    self.app_id,\n                    \"{}@{}\".format(\n                        re.split(r\"[\\s@]+\", self.user)[0],\n                        self.host,\n                    ),\n                ]\n\n            else:\n                result = is_email(self.user)\n                if result:\n                    # Prepare the bases of our email and include domain\n                    self.host = result[\"domain\"]\n                    self.from_addr = [self.app_id, self.user]\n\n        if from_addr:\n            result = is_email(from_addr)\n            if result:\n                self.from_addr = (\n                    result[\"name\"] if result[\"name\"] else False,\n                    result[\"full_email\"],\n                )\n            else:\n                # Only update the string but use the already detected info\n                self.from_addr[0] = from_addr\n\n        result = is_email(self.from_addr[1])\n        if not result:\n            # Parse Source domain based on from_addr\n            msg = \"Invalid ~From~ email specified: {}\".format(\n                \"{} <{}>\".format(self.from_addr[0], self.from_addr[1])\n                if self.from_addr[0]\n                else \"{}\".format(self.from_addr[1])\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our lookup\n        self.names[self.from_addr[1]] = self.from_addr[0]\n\n        if targets:\n            # Validate recipients (to:) and drop bad ones:\n            for recipient in parse_emails(targets):\n                result = is_email(recipient)\n                if result:\n                    self.targets.append((\n                        result[\"name\"] if result[\"name\"] else False,\n                        result[\"full_email\"],\n                    ))\n                    continue\n\n                self.logger.warning(\n                    \"Dropped invalid To email ({}) specified.\".format(\n                        recipient\n                    ),\n                )\n\n        else:\n            # If our target email list is empty we want to add ourselves to it\n            self.targets.append((False, self.from_addr[1]))\n\n        if not self.secure and self.secure_mode != SecureMailMode.INSECURE:\n            # Enable Secure mode if not otherwise set\n            self.secure = True\n\n        if not self.port:\n            # Assign our port based on our secure_mode if not otherwise\n            # detected\n            self.port = SECURE_MODES[self.secure_mode][\"default_port\"]\n\n        # if there is still no smtp_host then we fall back to the hostname\n        if not self.smtp_host:\n            self.smtp_host = self.host\n\n        # Prepare our Pretty Good Privacy Object\n        self.pgp = _pgp.ApprisePGPController(\n            path=self.store.path,\n            pub_keyfile=pgp_key,\n            email=self.from_addr[1],\n            asset=self.asset,\n        )\n\n        # We store so we can generate a URL later on\n        self.pgp_key = pgp_key\n\n        self.use_pgp = (\n            use_pgp if not None else self.template_args[\"pgp\"][\"default\"]\n        )\n\n        if self.use_pgp and not _pgp.PGP_SUPPORT:\n            self.logger.warning(\n                \"PGP Support is not available on this installation; \"\n                \"ask admin to install PGPy\"\n            )\n\n        return\n\n    def apply_email_defaults(self, secure_mode=None, port=None, **kwargs):\n        \"\"\"\n        A function that prefills defaults based on the email\n        it was provided.\n        \"\"\"\n\n        if self.smtp_host:\n            # SMTP Server was explicitly specified, therefore it is assumed\n            # the caller knows what he's doing and is intentionally\n            # over-riding any smarts to be applied. We also can not apply\n            # any default if there was no user specified.\n            return\n\n        # detect our email address using our user/host combo\n        from_addr = (\n            \"{}@{}\".format(\n                re.split(r\"[\\s@]+\", self.user)[0],\n                self.host,\n            )\n            if self.user\n            else self.host\n        )\n\n        for i in range(len(templates.EMAIL_TEMPLATES)):  # pragma: no branch\n            self.logger.trace(\n                \"Scanning %s against %s\",\n                from_addr, templates.EMAIL_TEMPLATES[i][0])\n\n            match = templates.EMAIL_TEMPLATES[i][1].match(from_addr)\n            if match:\n                self.logger.info(\n                    f\"Applying {templates.EMAIL_TEMPLATES[i][0]} Defaults\")\n\n                # the secure flag can not be altered if defined in the template\n                self.secure = templates.EMAIL_TEMPLATES[i][2].get(\n                    \"secure\", self.secure\n                )\n\n                # The SMTP Host check is already done above; if it was\n                # specified we wouldn't even reach this part of the code.\n                self.smtp_host = templates.EMAIL_TEMPLATES[i][2].get(\n                    \"smtp_host\", self.smtp_host\n                )\n\n                # The following can be over-ridden if defined manually in the\n                # Apprise URL.  Otherwise they take on the template value\n                if not port:\n                    self.port = templates.EMAIL_TEMPLATES[i][2].get(\n                        \"port\", self.port\n                    )\n                if not secure_mode:\n                    self.secure_mode = templates.EMAIL_TEMPLATES[i][2].get(\n                        \"secure_mode\", self.secure_mode\n                    )\n\n                # Adjust email login based on the defined usertype. If no entry\n                # was specified, then we default to having them all set (which\n                # basically implies that there are no restrictions and use use\n                # whatever was specified)\n                login_type = templates.EMAIL_TEMPLATES[i][2].get(\n                    \"login_type\", []\n                )\n                if login_type:\n                    # only apply additional logic to our user if a login_type\n                    # was specified.\n                    if is_email(self.user):\n                        if WebBaseLogin.EMAIL not in login_type:\n                            # Email specified but login type\n                            # not supported; switch it to user id\n                            self.user = match.group(\"id\")\n\n                        else:\n                            # Enforce our host information\n                            self.host = self.user.split(\"@\")[1]\n\n                    elif WebBaseLogin.USERID not in login_type:\n                        # user specified but login type\n                        # not supported; switch it to email\n                        self.user = \"{}@{}\".format(self.user, self.host)\n\n                if (\n                    \"from_user\" in templates.EMAIL_TEMPLATES[i][2]\n                    and not self.from_addr[1]\n                ):\n\n                    # Update our from address if defined\n                    self.from_addr[1] = \"{}@{}\".format(\n                        templates.EMAIL_TEMPLATES[i][2][\"from_user\"], self.host\n                    )\n\n                break\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n\n        if not self.targets:\n            # There is no one to email; we're done\n            logger.warning(\"There are no Email recipients to notify\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # bind the socket variable to the current namespace\n        socket = None\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            self.logger.debug(\"Connecting to remote SMTP server...\")\n            socket_func = smtplib.SMTP\n            if self.secure_mode == SecureMailMode.SSL:\n                self.logger.debug(\"Securing connection with SSL...\")\n                socket_func = smtplib.SMTP_SSL\n\n            socket = socket_func(\n                self.smtp_host,\n                self.port,\n                None,\n                timeout=self.socket_connect_timeout,\n            )\n\n            if self.secure_mode == SecureMailMode.STARTTLS:\n                # Handle Secure Connections\n                self.logger.debug(\"Securing connection with STARTTLS...\")\n                socket.starttls()\n\n            self.logger.trace(\"Login ID: {}\".format(self.user))\n            if self.user and self.password:\n                # Apply Login credentials\n                self.logger.debug(\"Applying user credentials...\")\n                socket.login(self.user, self.password)\n\n            # Prepare our headers\n            headers = {\n                \"X-Application\": self.app_id,\n            }\n            headers.update(self.headers)\n\n            # Iterate over our email messages we can generate and then\n            # send them off.\n            for message in NotifyEmail.prepare_emails(\n                subject=title,\n                body=body,\n                notify_format=self.notify_format,\n                from_addr=self.from_addr,\n                to=self.targets,\n                cc=self.cc,\n                bcc=self.bcc,\n                reply_to=self.reply_to,\n                smtp_host=self.smtp_host,\n                attach=attach,\n                headers=headers,\n                names=self.names,\n                pgp=self.pgp if self.use_pgp else None,\n                tzinfo=self.tzinfo,\n            ):\n                try:\n                    socket.sendmail(\n                        self.from_addr[1], message.to_addrs, message.body\n                    )\n\n                    self.logger.info(\"Sent Email to %s\", message.recipient)\n\n                except (OSError, smtplib.SMTPException, RuntimeError) as e:\n                    self.logger.warning(\n                        'Sending email to \"%s\" failed.', message.recipient\n                    )\n                    self.logger.debug(f\"Socket Exception: {e}\")\n\n                    # Mark as failure\n                    has_error = True\n\n        except (OSError, smtplib.SMTPException, RuntimeError) as e:\n            self.logger.warning(\n                'Connection error while submitting email to \"%s\"',\n                self.smtp_host,\n            )\n            self.logger.debug(f\"Socket Exception: {e}\")\n\n            # Mark as failure\n            has_error = True\n\n        except AppriseEmailException as e:\n            self.logger.debug(f\"Socket Exception: {e}\")\n\n            # Mark as failure\n            has_error = True\n\n        finally:\n            # Gracefully terminate the connection with the server\n            if socket is not None:\n                socket.quit()\n\n        # Reduce our dictionary (eliminate expired keys if any)\n        self.pgp.prune()\n\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"\n        Returns the URL built dynamically based on specified arguments.\n        \"\"\"\n\n        # Define an URL parameters\n        params = {\n            \"pgp\": \"yes\" if self.use_pgp else \"no\",\n        }\n\n        # Store our public key back into your URL\n        if self.pgp_key is not None:\n            params[\"pgp_key\"] = NotifyEmail.quote(self.pgp_key, safe=\":\\\\/\")\n\n        # Append our headers into our parameters\n        params.update({\"+{}\".format(k): v for k, v in self.headers.items()})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        from_addr = None\n        if len(self.targets) == 1 and self.targets[0][1] != self.from_addr[1]:\n            # A custom email was provided\n            from_addr = self.from_addr[1]\n\n        if self.smtp_host != self.host:\n            # Apply our SMTP Host only if it differs from the provided hostname\n            params[\"smtp\"] = self.smtp_host\n\n        if self.secure:\n            # Mode is only required if we're dealing with a secure connection\n            params[\"mode\"] = self.secure_mode\n\n        if self.from_addr[0] and self.from_addr[0] != self.app_id:\n            # A custom name was provided\n            params[\"from\"] = (\n                self.from_addr[0]\n                if not from_addr\n                else formataddr(\n                    (self.from_addr[0], from_addr), charset=\"utf-8\"\n                )\n            )\n\n        elif from_addr:\n            params[\"from\"] = formataddr((False, from_addr), charset=\"utf-8\")\n\n        elif not self.user:\n            params[\"from\"] = formataddr(\n                (False, self.from_addr[1]), charset=\"utf-8\"\n            )\n\n        if self.cc:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for it's escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\",\n                ).replace(\",\", \"%2C\")\n                for e in self.cc\n            ])\n\n        if self.bcc:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for it's escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\",\n                ).replace(\",\", \"%2C\")\n                for e in self.bcc\n            ])\n\n        if self.reply_to:\n            # Handle our Reply-To Addresses\n            params[\"reply\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for its escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\",\n                ).replace(\",\", \"%2C\")\n                for e in self.reply_to\n            ])\n\n        # pull email suffix from username (if present)\n        user = None if not self.user else self.user.split(\"@\")[0]\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyEmail.quote(user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif user:\n            # user url\n            auth = \"{user}@\".format(\n                user=NotifyEmail.quote(user, safe=\"\"),\n            )\n\n        # Default Port setup\n        default_port = SECURE_MODES[self.secure_mode][\"default_port\"]\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0][1] == self.from_addr[1]\n        )\n\n        return \"{schema}://{auth}{hostname}{port}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else \":{}\".format(self.port)\n            ),\n            targets=(\n                \"\"\n                if not has_targets\n                else \"/\".join([\n                    NotifyEmail.quote(\n                        \"{}{}\".format(\n                            \"\" if not e[0] else \"{}:\".format(e[0]), e[1]\n                        ),\n                        safe=\"\",\n                    )\n                    for e in self.targets\n                ])\n            ),\n            params=NotifyEmail.urlencode(params),\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"\n        Returns all of the identifiers that make this URL unique from\n        another similar one. Targets or end points should never be identified\n        here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.smtp_host,\n            (\n                self.port\n                if self.port\n                else SECURE_MODES[self.secure_mode][\"default_port\"]\n            ),\n        )\n\n    def __len__(self):\n        \"\"\"\n        Returns the number of targets associated with this notification\n        \"\"\"\n        return len(self.targets) if self.targets else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"\n        Parses the URL and returns enough arguments that can allow\n        us to re-instantiate this object.\n\n        \"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Prepare our target lists\n        results[\"targets\"] = []\n\n        if is_ipaddr(results[\"host\"]):\n            # Silently move on and do not disrupt any configuration\n            pass\n\n        elif not is_hostname(\n            results[\"host\"], ipv4=False, ipv6=False, underscore=False\n        ):\n\n            if is_email(NotifyEmail.unquote(results[\"host\"])):\n                # Don't lose defined email addresses\n                results[\"targets\"].append(NotifyEmail.unquote(results[\"host\"]))\n\n            # Detect if we have a valid hostname or not; be sure to reset it's\n            # value if invalid; we'll attempt to figure this out later on\n            results[\"host\"] = \"\"\n\n        # Get PGP Flag\n        results[\"use_pgp\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"pgp\", NotifyEmail.template_args[\"pgp\"][\"default\"]\n            )\n        )\n\n        # Get PGP Public Key Override\n        if \"pgpkey\" in results[\"qsd\"] and results[\"qsd\"][\"pgpkey\"]:\n            results[\"pgp_key\"] = NotifyEmail.unquote(results[\"qsd\"][\"pgpkey\"])\n\n        # The From address is a must; either through the use of templates\n        # from= entry and/or merging the user and hostname together, this\n        # must be calculated or parse_url will fail.\n        from_addr = \"\"\n\n        # The server we connect to to send our mail to\n        smtp_host = \"\"\n\n        # Get our potential email targets; if none our found we'll just\n        # add one to ourselves\n        results[\"targets\"] += NotifyEmail.split_path(results[\"fullpath\"])\n\n        # Attempt to detect 'to' email address\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(results[\"qsd\"][\"to\"])\n\n        # Attempt to detect 'from' email address\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            from_addr = NotifyEmail.unquote(results[\"qsd\"][\"from\"])\n\n            if \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n                from_addr = formataddr(\n                    (NotifyEmail.unquote(results[\"qsd\"][\"name\"]), from_addr),\n                    charset=\"utf-8\",\n                )\n\n        elif \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n            # Extract from name to associate with from address\n            from_addr = NotifyEmail.unquote(results[\"qsd\"][\"name\"])\n\n        # Store SMTP Host if specified\n        if \"smtp\" in results[\"qsd\"] and len(results[\"qsd\"][\"smtp\"]):\n            # Extract the smtp server\n            smtp_host = NotifyEmail.unquote(results[\"qsd\"][\"smtp\"])\n\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            # Extract the secure mode to over-ride the default\n            results[\"secure_mode\"] = results[\"qsd\"][\"mode\"].lower()\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = results[\"qsd\"][\"cc\"]\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = results[\"qsd\"][\"bcc\"]\n\n        # Handle Reply To Addresses\n        if \"reply\" in results[\"qsd\"] and len(results[\"qsd\"][\"reply\"]):\n            results[\"reply_to\"] = results[\"qsd\"][\"reply\"]\n\n        results[\"from_addr\"] = from_addr\n        results[\"smtp_host\"] = smtp_host\n\n        # Add our Meta Headers that the user can provide with their outbound\n        # emails\n        results[\"headers\"] = {\n            NotifyBase.unquote(x): NotifyBase.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        return results\n\n    @staticmethod\n    def _get_charset(input_string):\n        \"\"\"\n        Get utf-8 charset if non ascii string only\n\n        Encode an ascii string to utf-8 is bad for email deliverability\n        because some anti-spam gives a bad score for that\n        like SUBJ_EXCESS_QP flag on Rspamd\n        \"\"\"\n        if not input_string:\n            return None\n        return \"utf-8\" if not all(ord(c) < 128 for c in input_string) else None\n\n    @staticmethod\n    def prepare_emails(\n        subject,\n        body,\n        from_addr,\n        to,\n        cc: Optional[set] = None,\n        bcc: Optional[set] = None,\n        reply_to: Optional[set] = None,\n        # Providing an SMTP Host helps improve Email Message-ID\n        # and avoids getting flagged as spam\n        smtp_host=None,\n        # Can be either 'html' or 'text'\n        notify_format=NotifyFormat.HTML,\n        attach=None,\n        headers: Optional[dict] = None,\n        # Names can be a dictionary\n        names=None,\n        # Pretty Good Privacy Support; Pass in an\n        # ApprisePGPController if you wish to use it\n        pgp=None,\n        # Define our timezone; if one isn't provided, then we use\n        # the system time instead\n        tzinfo=None,\n    ):\n        \"\"\"\n        Generator for emails\n            from_addr: must be in format: (from_name, from_addr)\n            to: must be in the format:\n                 [(to_name, to_addr), (to_name, to_addr)), ...]\n            cc: must be a set of email addresses\n            bcc: must be a set of email addresses\n            reply_to: must be either None, or an email address\n            smtp_host: This is used to generate the email's Message-ID. Set\n                       this correctly to avoid getting flagged as Spam\n            notify_format: can be either 'text' or 'html'\n            attach: must be of class AppriseAttachment\n            headers: Optionally provide a dictionary of additional headers you\n                     would like to include in the email payload\n            names: This is a dictionary of email addresses as keys and the\n                   Names to associate with them when sending the email.\n                   This is cross referenced for the cc and bcc lists\n            pgp:   Encrypting the message using Pretty Good Privacy support\n                   This requires that the pgp_path provided exists and\n                   keys can be referenced here to perform the encryption\n                   with. If a key isn't found, one will be generated.\n\n                   pgp support requires the 'PGPy' Python library to be\n                   available.\n\n                   Pass in an ApprisePGPController() if you wish to use this\n        \"\"\"\n        if not to:\n            # There is no one to email; we're done\n            msg = \"There are no Email recipients to notify\"\n            logger.warning(msg)\n            raise AppriseEmailException(msg) from None\n\n        elif pgp and not _pgp.PGP_SUPPORT:\n            msg = \"PGP Support unavailable; install PGPy library\"\n            logger.warning(msg)\n            raise AppriseEmailException(msg) from None\n\n        if headers is None:\n            headers = {}\n\n        if cc is None:\n            cc = set()\n\n        if bcc is None:\n            bcc = set()\n\n        if reply_to is None:\n            reply_to = set()\n\n        if not names:\n            # Prepare a empty dictionary to prevent errors/warnings\n            names = {}\n\n        if not smtp_host:\n            # Generate a host identifier (used for Message-ID Creation)\n            smtp_host = from_addr[1].split(\"@\")[1]\n\n        if not tzinfo:\n            # use server time\n            tzinfo = datetime.now().astimezone().tzinfo\n\n        logger.debug(f\"SMTP Host: {smtp_host}\")\n\n        # Create a copy of the targets list\n        emails = list(to)\n        while len(emails):\n            # Get our email to notify\n            to_name, to_addr = emails.pop(0)\n\n            # Strip target out of cc list if in To or Bcc\n            cc_ = cc - bcc - {to_addr}\n\n            # Strip target out of bcc list if in To\n            bcc_ = bcc - {to_addr}\n\n            # Strip target out of reply_to list if in To\n            reply_to_ = reply_to - {to_addr}\n\n            # Format our cc addresses to support the Name field\n            cc_ = [\n                formataddr((names.get(addr, False), addr), charset=\"utf-8\")\n                for addr in cc_\n            ]\n\n            # Format our bcc addresses to support the Name field\n            bcc_ = [\n                formataddr((names.get(addr, False), addr), charset=\"utf-8\")\n                for addr in bcc_\n            ]\n\n            if reply_to_:\n                # Format our reply-to addresses to support the Name field\n                reply_to = [\n                    formataddr((names.get(addr, False), addr), charset=\"utf-8\")\n                    for addr in reply_to_\n                ]\n\n            logger.debug(\n                \"Email From: {}\".format(formataddr(from_addr, charset=\"utf-8\"))\n            )\n\n            logger.debug(\"Email To: {}\".format(to_addr))\n            if cc_:\n                logger.debug(\"Email Cc: {}\".format(\", \".join(cc_)))\n            if bcc_:\n                logger.debug(\"Email Bcc: {}\".format(\", \".join(bcc_)))\n            if reply_to_:\n                logger.debug(\"Email Reply-To: {}\".format(\", \".join(reply_to_)))\n\n            # Prepare Email Message\n            if notify_format == NotifyFormat.HTML:\n                base = MIMEMultipart(\"alternative\")\n                base.attach(\n                    MIMEText(\n                        convert_between(\n                            NotifyFormat.HTML, NotifyFormat.TEXT, body\n                        ),\n                        \"plain\",\n                        \"utf-8\",\n                    )\n                )\n                base.attach(MIMEText(body, \"html\", \"utf-8\"))\n            else:\n                base = MIMEText(body, \"plain\", \"utf-8\")\n\n            if attach:\n                mixed = MIMEMultipart(\"mixed\")\n                mixed.attach(base)\n                # Now store our attachments\n                for no, attachment in enumerate(attach, start=1):\n                    if not attachment:\n                        # We could not load the attachment; take an early\n                        # exit since this isn't what the end user wanted\n\n                        # We could not access the attachment\n                        msg = \"Could not access attachment {}.\".format(\n                            attachment.url(privacy=True)\n                        )\n                        logger.warning(msg)\n                        raise AppriseEmailException(msg)\n\n                    logger.debug(\n                        \"Preparing Email attachment {}\".format(\n                            attachment.url(privacy=True)\n                        )\n                    )\n\n                    with open(attachment.path, \"rb\") as abody:\n                        app = MIMEApplication(abody.read())\n                        app.set_type(attachment.mimetype)\n\n                        # Prepare our attachment name\n                        filename = (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        )\n\n                        app.add_header(\n                            \"Content-Disposition\",\n                            'attachment; filename=\"{}\"'.format(\n                                Header(filename, \"utf-8\")\n                            ),\n                        )\n                        mixed.attach(app)\n                base = mixed\n\n            if pgp:\n                logger.debug(\"Securing Email with PGP Encryption\")\n                # Set our header information to include in the encryption\n                base[\"From\"] = formataddr(\n                    (None, from_addr[1]), charset=\"utf-8\"\n                )\n                base[\"To\"] = formataddr((None, to_addr), charset=\"utf-8\")\n                base[\"Subject\"] = Header(\n                    subject, NotifyEmail._get_charset(subject)\n                )\n\n                # Apply our encryption\n                encrypted_content = pgp.encrypt(base.as_string(), to_addr)\n\n                if not encrypted_content:\n                    # Unable to send notification\n                    msg = \"Unable to encrypt email via PGP\"\n                    logger.warning(msg)\n                    raise AppriseEmailException(msg)\n\n                # prepare our message\n                base = MIMEMultipart(\n                    \"encrypted\", protocol=\"application/pgp-encrypted\"\n                )\n\n                # Store Autocrypt header (DeltaChat Support)\n                base.add_header(\n                    \"Autocrypt\",\n                    f\"addr={formataddr((False, to_addr), charset='utf-8')}; \"\n                    \"prefer-encrypt=mutual\"\n                )\n\n                # Set Encryption Info Part\n                enc_payload = MIMEText(\"Version: 1\", \"plain\")\n                enc_payload.set_type(\"application/pgp-encrypted\")\n                base.attach(enc_payload)\n\n                enc_payload = MIMEBase(\"application\", \"octet-stream\")\n                enc_payload.set_payload(encrypted_content)\n                base.attach(enc_payload)\n\n            # Apply any provided custom headers\n            for k, v in headers.items():\n                base[k] = Header(v, NotifyEmail._get_charset(v))\n\n            base[\"Subject\"] = Header(\n                subject, NotifyEmail._get_charset(subject)\n            )\n            base[\"From\"] = formataddr(from_addr, charset=\"utf-8\")\n            base[\"To\"] = formataddr((to_name, to_addr), charset=\"utf-8\")\n            base[\"Message-ID\"] = make_msgid(domain=smtp_host)\n            base[\"Date\"] = format_datetime(datetime.now(tz=tzinfo))\n\n            if cc:\n                base[\"Cc\"] = \",\".join(cc_)\n\n            if reply_to_:\n                base[\"Reply-To\"] = \",\".join(reply_to)\n\n            yield EmailMessage(\n                recipient=to_addr,\n                to_addrs=[to_addr, *list(cc_), *list(bcc_)],\n                body=base.as_string(),\n            )\n"
  },
  {
    "path": "apprise/plugins/email/common.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\nimport dataclasses\n\nfrom ...exception import ApprisePluginException\n\n\nclass AppriseEmailException(ApprisePluginException):\n    \"\"\"\n    Thrown when there is an error with the Email Attachment\n    \"\"\"\n\n    def __init__(self, message, error_code=601):\n        super().__init__(message, error_code=error_code)\n\n\nclass WebBaseLogin:\n    \"\"\"\n    This class is just used in conjunction of the default emailers\n    to best formulate a login to it using the data detected\n    \"\"\"\n\n    # User Login must be Email Based\n    EMAIL = \"Email\"\n\n    # User Login must UserID Based\n    USERID = \"UserID\"\n\n\n# Secure Email Modes\nclass SecureMailMode:\n    INSECURE = \"insecure\"\n    SSL = \"ssl\"\n    STARTTLS = \"starttls\"\n\n\n# Define all of the secure modes (used during validation)\nSECURE_MODES = {\n    SecureMailMode.STARTTLS: {\n        \"default_port\": 587,\n    },\n    SecureMailMode.SSL: {\n        \"default_port\": 465,\n    },\n    SecureMailMode.INSECURE: {\n        \"default_port\": 25,\n    },\n}\n\n\n@dataclasses.dataclass\nclass EmailMessage:\n    \"\"\"\n    Our message structure\n    \"\"\"\n\n    recipient: str\n    to_addrs: list[str]\n    body: str\n"
  },
  {
    "path": "apprise/plugins/email/templates.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\nimport re\n\nfrom .common import SecureMailMode, WebBaseLogin\n\n# To attempt to make this script stupid proof, if we detect an email address\n# that is part of the this table, we can pre-use a lot more defaults if they\n# aren't otherwise specified on the users input.\nEMAIL_TEMPLATES = (\n    # Google GMail\n    (\n        \"Google Mail\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\" r\"(?P<domain>gmail\\.com)$\",\n            re.I,\n        ),\n        {\n            \"port\": 587,\n            \"smtp_host\": \"smtp.gmail.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Yandex\n    (\n        \"Yandex\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>yandex\\.(com|ru|ua|by|kz|uz|tr|fr))$\",\n            re.I,\n        ),\n        {\n            \"port\": 465,\n            \"smtp_host\": \"smtp.yandex.ru\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.SSL,\n            \"login_type\": (WebBaseLogin.USERID,),\n        },\n    ),\n    # Microsoft Hotmail\n    (\n        \"Microsoft Hotmail\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>(hotmail|live)\\.com(\\.au)?)$\",\n            re.I,\n        ),\n        {\n            \"port\": 587,\n            \"smtp_host\": \"smtp-mail.outlook.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Microsoft Outlook\n    (\n        \"Microsoft Outlook\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>(smtp\\.)?outlook\\.com(\\.au)?)$\",\n            re.I,\n        ),\n        {\n            \"port\": 587,\n            \"smtp_host\": \"smtp.outlook.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Microsoft Office 365 (Email Server)\n    # You must specify an authenticated sender address in the from= settings\n    # and a valid email in the to= to deliver your emails to\n    (\n        \"Microsoft Office 365\",\n        re.compile(r\"^[^@]+@(?P<domain>(smtp\\.)?office365\\.com)$\", re.I),\n        {\n            \"port\": 587,\n            \"smtp_host\": \"smtp.office365.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n        },\n    ),\n    # Yahoo Mail\n    (\n        \"Yahoo Mail\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>yahoo\\.(ca|com))$\",\n            re.I,\n        ),\n        {\n            \"port\": 465,\n            \"smtp_host\": \"smtp.mail.yahoo.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # GMX Mail\n    (\n        \"GMX Mail\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>gmx\\.(com|net|de|at|ch|fr))$\",\n            re.I,\n        ),\n        {\n            \"port\": 587,\n            \"smtp_host\": \"mail.gmx.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Fast Mail (Series 1)\n    (\n        \"Fast Mail\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>fastmail\\.(com|cn|co\\.uk|com\\.au|de|es|fm|fr|im|\"\n            r\"in|jp|mx|net|nl|org|se|to|tw|uk|us))$\",\n            re.I,\n        ),\n        {\n            \"port\": 465,\n            \"smtp_host\": \"smtp.fastmail.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.SSL,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Fast Mail (Series 2)\n    (\n        \"Fast Mail Extended Addresses\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>123mail\\.org|airpost\\.net|eml\\.cc|fmail\\.co\\.uk|\"\n            r\"fmgirl\\.com|fmguy\\.com|mailbolt\\.com|mailcan\\.com|\"\n            r\"mailhaven\\.com|mailmight\\.com|ml1\\.net|mm\\.st|myfastmail\\.com|\"\n            r\"proinbox\\.com|promessage\\.com|rushpost\\.com|sent\\.(as|at|com)|\"\n            r\"speedymail\\.org|warpmail\\.net|xsmail\\.com|150mail\\.com|\"\n            r\"150ml\\.com|16mail\\.com|2-mail\\.com|4email\\.net|50mail\\.com|\"\n            r\"allmail\\.net|bestmail\\.us|cluemail\\.com|elitemail\\.org|\"\n            r\"emailcorner\\.net|emailengine\\.(net|org)|emailgroups\\.net|\"\n            r\"emailplus\\.org|emailuser\\.net|f-m\\.fm|fast-email\\.com|\"\n            r\"fast-mail\\.org|fastem\\.com|fastemail\\.us|fastemailer\\.com|\"\n            r\"fastest\\.cc|fastimap\\.com|fastmailbox\\.net|fastmessaging\\.com|\"\n            r\"fea\\.st|fmailbox\\.com|ftml\\.net|h-mail\\.us|hailmail\\.net|\"\n            r\"imap-mail\\.com|imap\\.cc|imapmail\\.org|inoutbox\\.com|\"\n            r\"internet-e-mail\\.com|internet-mail\\.org|internetemails\\.net|\"\n            r\"internetmailing\\.net|jetemail\\.net|justemail\\.net|\"\n            r\"letterboxes\\.org|mail-central\\.com|mail-page\\.com|\"\n            r\"mailandftp\\.com|mailas\\.com|mailc\\.net|mailforce\\.net|\"\n            r\"mailftp\\.com|mailingaddress\\.org|mailite\\.com|mailnew\\.com|\"\n            r\"mailsent\\.net|mailservice\\.ms|mailup\\.net|mailworks\\.org|\"\n            r\"mymacmail\\.com|nospammail\\.net|ownmail\\.net|petml\\.com|\"\n            r\"postinbox\\.com|postpro\\.net|realemail\\.net|reallyfast\\.biz|\"\n            r\"reallyfast\\.info|speedpost\\.net|ssl-mail\\.com|swift-mail\\.com|\"\n            r\"the-fastest\\.net|the-quickest\\.com|theinternetemail\\.com|\"\n            r\"veryfast\\.biz|veryspeedy\\.net|yepmail\\.net)$\",\n            re.I,\n        ),\n        {\n            \"port\": 465,\n            \"smtp_host\": \"smtp.fastmail.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.SSL,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Zoho Mail (Free)\n    (\n        \"Zoho Mail\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>zoho(mail)?\\.com)$\",\n            re.I,\n        ),\n        {\n            \"port\": 587,\n            \"smtp_host\": \"smtp.zoho.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # SendGrid (Email Server)\n    # You must specify an authenticated sender address in the from= settings\n    # and a valid email in the to= to deliver your emails to\n    (\n        \"SendGrid\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>(\\.smtp)?sendgrid\\.(com|net))$\",\n            re.I,\n        ),\n        {\n            \"port\": 465,\n            \"smtp_host\": \"smtp.sendgrid.net\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.SSL,\n            \"login_type\": (WebBaseLogin.USERID,),\n        },\n    ),\n    # 163.com\n    (\n        \"163.com\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\" r\"(?P<domain>163\\.com)$\",\n            re.I,\n        ),\n        {\n            \"port\": 465,\n            \"smtp_host\": \"smtp.163.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.SSL,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Foxmail.com\n    (\n        \"Foxmail.com\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>(foxmail|qq)\\.com)$\",\n            re.I,\n        ),\n        {\n            \"port\": 587,\n            \"smtp_host\": \"smtp.qq.com\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.STARTTLS,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Comcast.net\n    (\n        \"Comcast.net\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\"\n            r\"(?P<domain>(comcast)\\.net)$\",\n            re.I,\n        ),\n        {\n            \"port\": 465,\n            \"smtp_host\": \"smtp.comcast.net\",\n            \"secure\": True,\n            \"secure_mode\": SecureMailMode.SSL,\n            \"login_type\": (WebBaseLogin.EMAIL,),\n        },\n    ),\n    # Localhost handling\n    (\n        \"Local Mail Server\",\n        re.compile(\n            r\"^(((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@)?\"\n            r\"(?P<domain>localhost(\\.localdomain)?)$\",\n            re.I,\n        ),\n        {\n            # Provide a default user if one isn't provided\n            \"from_user\": \"root\",\n            \"smtp_host\": None,\n        },\n    ),\n    # Catch All\n    (\n        \"Custom\",\n        re.compile(\n            r\"^((?P<label>[^+]+)\\+)?(?P<id>[^@]+)@\" r\"(?P<domain>.+)$\", re.I\n        ),\n        {\n            # Setting smtp_host to None is a way of\n            # auto-detecting it based on other parameters\n            # specified.  There is no reason to ever modify\n            # this Catch All\n            \"smtp_host\": None,\n        },\n    ),\n)\n"
  },
  {
    "path": "apprise/plugins/emby.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Emby Docker configuration: https://hub.docker.com/r/emby/embyserver/\n# Authentication: https://github.com/MediaBrowser/Emby/wiki/Authentication\n# Notifications: https://github.com/MediaBrowser/Emby/wiki/Remote-control\nimport hashlib\nfrom json import dumps, loads\n\nimport requests\n\nfrom .. import __version__ as VERSION\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n\nclass NotifyEmby(NotifyBase):\n    \"\"\"A wrapper for Emby Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Emby\"\n\n    # The services URL\n    service_url = \"https://emby.media/\"\n\n    # The default protocol\n    protocol = \"emby\"\n\n    # The default secure protocol\n    secure_protocol = \"embys\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/emby/\"\n\n    # By default Emby requires you to provide it a device id\n    # The following was just a random uuid4 generated one.  There\n    # is no real reason to change this, but hey; that's what open\n    # source is for right?\n    emby_device_id = \"48df9504-6843-49be-9f2d-a685e25a0bc8\"\n\n    # The Emby message timeout; basically it is how long should our message be\n    # displayed for.  The value is in milli-seconds\n    emby_message_timeout_ms = 60000\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n                \"default\": 8096,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"modal\": {\n                \"name\": _(\"Modal\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(self, modal=False, **kwargs):\n        \"\"\"Initialize Emby Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if self.secure:\n            self.schema = \"https\"\n\n        else:\n            self.schema = \"http\"\n\n        # Our access token does not get created until we first\n        # authenticate with our Emby server. The same goes for the\n        # user id below.\n        self.access_token = None\n        self.user_id = None\n\n        # Whether or not our popup dialog is a timed notification\n        # or a modal type box (requires an Okay acknowledgement)\n        self.modal = modal\n\n        if not self.port:\n            # Assign default port if one isn't otherwise specified:\n            self.port = self.template_tokens[\"port\"][\"default\"]\n\n        if not self.user:\n            # User was not specified\n            msg = \"No Emby username was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    def login(self, **kwargs):\n        \"\"\"Creates our authentication token and prepares our header.\"\"\"\n\n        if self.is_authenticated:\n            # Log out first before we log back in\n            self.logout()\n\n        # Prepare our login url\n        url = f\"{self.schema}://{self.host}\"\n        if self.port:\n            url += f\":{self.port}\"\n\n        url += \"/Users/AuthenticateByName\"\n\n        # Initialize our payload\n        payload = {\"Username\": self.user}\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"X-Emby-Authorization\": self.emby_auth_header,\n        }\n\n        if self.password:\n            # Source: https://github.com/MediaBrowser/Emby/wiki/Authentication\n            # We require the following during our authentication\n            #    pw - password in plain text\n            #    password - password in Sha1\n            #    passwordMd5 - password in MD5\n            payload[\"pw\"] = self.password\n\n            password_md5 = hashlib.md5()\n            password_md5.update(self.password.encode(\"utf-8\"))\n            payload[\"passwordMd5\"] = password_md5.hexdigest()\n\n            password_sha1 = hashlib.sha1()\n            password_sha1.update(self.password.encode(\"utf-8\"))\n            payload[\"password\"] = password_sha1.hexdigest()\n\n        else:\n            # Backwards compatibility\n            payload[\"password\"] = \"\"\n            payload[\"passwordMd5\"] = \"\"\n\n            # April 1st, 2018 and newer requirement:\n            payload[\"pw\"] = \"\"\n\n        self.logger.debug(\n            \"Emby login() POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n\n        try:\n            r = requests.post(\n                url,\n                headers=headers,\n                data=dumps(payload),\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyEmby.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to authenticate Emby user {} details: \"\n                    \"{}{}error={}.\".format(\n                        self.user,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred authenticating a user with Emby \"\n                f\"at {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        # Load our results\n        try:\n            results = loads(r.content)\n\n        except (AttributeError, TypeError, ValueError):\n            # ValueError = r.content is Unparsable\n            # TypeError = r.content is None\n            # AttributeError = r is None\n\n            # This is a problem; abort\n            return False\n\n        # Acquire our Access Token\n        self.access_token = results.get(\"AccessToken\")\n\n        # Acquire our UserId. It can be in one (or both) of the\n        # following locations in the response:\n        #   {\n        #      'User': {\n        #         ...\n        #         'Id': 'the_user_id_can_be_here',\n        #         ...\n        #       },\n        #      'Id': 'the_user_id_can_be_found_here_too',\n        #   }\n        #\n        # The below just safely covers both grounds.\n        self.user_id = results.get(\"Id\")\n        if not self.user_id and \"User\" in results:\n            self.user_id = results[\"User\"].get(\"Id\")\n\n        # No user was found matching the specified\n        return self.is_authenticated\n\n    def sessions(self, user_controlled=True):\n        \"\"\"Acquire our Session Identifiers and store them in a dictionary\n        indexed by the session id itself.\"\"\"\n        # A single session might look like this:\n        # {\n        #    u'AdditionalUsers': [],\n        #    u'ApplicationVersion': u'3.3.1.0',\n        #    u'Client': u'Emby Mobile',\n        #    u'DeviceId': u'00c901e90ae814c00f81c75ae06a1c8a4381f45b',\n        #    u'DeviceName': u'Firefox',\n        #    u'Id': u'e37151ea06d7eb636639fded5a80f223',\n        #    u'LastActivityDate': u'2018-03-04T21:29:02.5590200Z',\n        #    u'PlayState': {\n        #       u'CanSeek': False,\n        #       u'IsMuted': False,\n        #       u'IsPaused': False,\n        #       u'RepeatMode': u'RepeatNone',\n        #    },\n        #    u'PlayableMediaTypes': [u'Audio', u'Video'],\n        #    u'RemoteEndPoint': u'172.17.0.1',\n        #    u'ServerId': u'4470e977ea704a08b264628c24127d43',\n        #    u'SupportedCommands': [\n        #       u'MoveUp',\n        #       u'MoveDown',\n        #       u'MoveLeft',\n        #       u'MoveRight',\n        #       u'PageUp',\n        #       u'PageDown',\n        #       u'PreviousLetter',\n        #       u'NextLetter',\n        #       u'ToggleOsd',\n        #       u'ToggleContextMenu',\n        #       u'Select',\n        #       u'Back',\n        #       u'SendKey',\n        #       u'SendString',\n        #       u'GoHome',\n        #       u'GoToSettings',\n        #       u'VolumeUp',\n        #       u'VolumeDown',\n        #       u'Mute',\n        #       u'Unmute',\n        #       u'ToggleMute',\n        #       u'SetVolume',\n        #       u'SetAudioStreamIndex',\n        #       u'SetSubtitleStreamIndex',\n        #       u'DisplayContent',\n        #       u'GoToSearch',\n        #       u'DisplayMessage',\n        #       u'SetRepeatMode',\n        #       u'ChannelUp',\n        #       u'ChannelDown',\n        #       u'PlayMediaSource',\n        #    ],\n        #    u'SupportsRemoteControl': True,\n        #    u'UserId': u'6f98d12cb10f48209ee282787daf7af6',\n        #    u'UserName': u'l2g'\n        #    }\n\n        # Prepare a dict() object to control our sessions; the keys are\n        # the sessions while the details associated with the session\n        # are stored inside.\n        sessions = {}\n\n        if not self.is_authenticated and not self.login():\n            # Authenticate if we aren't already\n            return sessions\n\n        # Prepare our login url\n        url = f\"{self.schema}://{self.host}\"\n        if self.port:\n            url += f\":{self.port}\"\n\n        url += \"/Sessions\"\n\n        if user_controlled is True:\n            # Only return sessions that can be managed by the current Emby\n            # user.\n            url += f\"?ControllableByUserId={self.user_id}\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"X-Emby-Authorization\": self.emby_auth_header,\n            \"X-MediaBrowser-Token\": self.access_token,\n        }\n\n        self.logger.debug(\n            \"Emby session() GET URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n\n        try:\n            r = requests.get(\n                url,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyEmby.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to acquire Emby session for user {}: \"\n                    \"{}{}error={}.\".format(\n                        self.user,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                # Return; we're done\n                return sessions\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred querying Emby \"\n                f\"for session information at {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return sessions\n\n        # Load our results\n        try:\n            results = loads(r.content)\n\n        except (AttributeError, TypeError, ValueError):\n            # ValueError = r.content is Unparsable\n            # TypeError = r.content is None\n            # AttributeError = r is None\n\n            # We need to abort at this point\n            return sessions\n\n        for entry in results:\n            session = entry.get(\"Id\")\n            if session:\n                sessions[session] = entry\n\n        return sessions\n\n    def logout(self, **kwargs):\n        \"\"\"Logs out of an already-authenticated session.\"\"\"\n        if not self.is_authenticated:\n            # We're not authenticated; there is nothing to do\n            return True\n\n        # Prepare our login url\n        url = f\"{self.schema}://{self.host}\"\n        if self.port:\n            url += f\":{self.port}\"\n\n        url += \"/Sessions/Logout\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"X-Emby-Authorization\": self.emby_auth_header,\n            \"X-MediaBrowser-Token\": self.access_token,\n        }\n\n        self.logger.debug(\n            \"Emby logout() POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        try:\n            r = requests.post(\n                url,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code not in (\n                # We're already logged out\n                requests.codes.unauthorized,\n                # The below show up if we were 'just' logged out\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n\n                # We had a problem\n                status_str = NotifyEmby.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to logoff Emby user {}: {}{}error={}.\".format(\n                        self.user,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                # Return; we're done\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred querying Emby \"\n                f\"to logoff user {self.user} at {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        # We logged our successfully if we reached here\n\n        # Reset our variables\n        self.access_token = None\n        self.user_id = None\n        return True\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Emby Notification.\"\"\"\n        if not self.is_authenticated and not self.login():\n            # Authenticate if we aren't already\n            return False\n\n        # Acquire our list of sessions\n        sessions = self.sessions().keys()\n        if not sessions:\n            self.logger.warning(\"There were no Emby sessions to notify.\")\n            # We don't need to fail; there really is no one to notify\n            return True\n\n        url = f\"{self.schema}://{self.host}\"\n        if self.port:\n            url += f\":{self.port}\"\n\n        # Append our remaining path\n        url += \"/Sessions/%s/Message\"\n\n        # Prepare Emby Object\n        payload = {\n            \"Header\": title,\n            \"Text\": body,\n        }\n\n        if not self.modal:\n            payload[\"TimeoutMs\"] = self.emby_message_timeout_ms\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"X-Emby-Authorization\": self.emby_auth_header,\n            \"X-MediaBrowser-Token\": self.access_token,\n        }\n\n        # Track whether or not we had a failure or not.\n        has_error = False\n\n        for session in sessions:\n            # Update our session\n            session_url = url % session\n\n            self.logger.debug(\n                \"Emby POST URL:\"\n                f\" {session_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Emby Payload: {payload!s}\")\n\n            # Always call throttle before the requests are made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    session_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.no_content,\n                ):\n                    # We had a problem\n                    status_str = NotifyEmby.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Emby notification: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\"Sent Emby notification.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Emby \"\n                    f\"notification to {self.host}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"modal\": \"yes\" if self.modal else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyEmby.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        else:  # self.user is set\n            auth = \"{user}@\".format(\n                user=NotifyEmby.quote(self.user, safe=\"\"),\n            )\n\n        return \"{schema}://{auth}{hostname}{port}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None\n                or self.port == self.template_tokens[\"port\"][\"default\"]\n                else f\":{self.port}\"\n            ),\n            params=NotifyEmby.urlencode(params),\n        )\n\n    @property\n    def is_authenticated(self):\n        \"\"\"Returns True if we're authenticated and False if not.\"\"\"\n        return bool(self.access_token and self.user_id)\n\n    @property\n    def emby_auth_header(self):\n        \"\"\"Generates the X-Emby-Authorization header response based on whether\n        we're authenticated or not.\"\"\"\n        # Specific to Emby\n        header_args = [\n            (\"MediaBrowser Client\", self.app_id),\n            (\"Device\", self.app_id),\n            (\"DeviceId\", self.emby_device_id),\n            (\"Version\", str(VERSION)),\n        ]\n\n        if self.user_id:\n            # Append UserId variable if we're authenticated\n            header_args.append((\"UserId\", self.user))\n\n        return \", \".join([f'{k}=\"{v}\"' for k, v in header_args])\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early\n            return results\n\n        # Modal type popup (default False)\n        results[\"modal\"] = parse_bool(results[\"qsd\"].get(\"modal\", False))\n\n        return results\n\n    def __del__(self):\n        \"\"\"Destructor.\"\"\"\n        self.logout()\n"
  },
  {
    "path": "apprise/plugins/enigma2.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Sources\n# - https://dreambox.de/en/\n# - https://dream.reichholf.net/wiki/Hauptseite\n# - https://dream.reichholf.net/wiki/Enigma2:WebInterface#Message\n# - https://github.com/E2OpenPlugins/e2openplugin-OpenWebif\n# - https://github.com/E2OpenPlugins/e2openplugin-OpenWebif/wiki/\\\n#       OpenWebif-API-documentation#message\n\nfrom json import loads\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom .base import NotifyBase\n\n\nclass Enigma2MessageType:\n    # Defines the Enigma2 notification types Apprise can map to\n    INFO = 1\n    WARNING = 2\n    ERROR = 3\n\n\n# If a mapping fails, the default of Enigma2MessageType.INFO is used\nMESSAGE_MAPPING = {\n    NotifyType.INFO: Enigma2MessageType.INFO,\n    NotifyType.SUCCESS: Enigma2MessageType.INFO,\n    NotifyType.WARNING: Enigma2MessageType.WARNING,\n    NotifyType.FAILURE: Enigma2MessageType.ERROR,\n}\n\n\nclass NotifyEnigma2(NotifyBase):\n    \"\"\"A wrapper for Enigma2 Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Enigma2\"\n\n    # The services URL\n    service_url = \"https://dreambox.de/\"\n\n    # The default protocol\n    protocol = \"enigma2\"\n\n    # The default secure protocol\n    secure_protocol = \"enigma2s\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/enigma2/\"\n\n    # Enigma2 does not support a title\n    title_maxlen = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1000\n\n    # Throttle a wee-bit to avoid thrashing\n    request_rate_per_sec = 0.5\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{user}@{host}\",\n        \"{schema}://{user}@{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n        \"{schema}://{host}/{fullpath}\",\n        \"{schema}://{host}:{port}/{fullpath}\",\n        \"{schema}://{user}@{host}/{fullpath}\",\n        \"{schema}://{user}@{host}:{port}/{fullpath}\",\n        \"{schema}://{user}:{password}@{host}/{fullpath}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{fullpath}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"fullpath\": {\n                \"name\": _(\"Path\"),\n                \"type\": \"string\",\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"timeout\": {\n                \"name\": _(\"Server Timeout\"),\n                \"type\": \"int\",\n                # The number of seconds to display the message for\n                \"default\": 13,\n                # -1 means infinit\n                \"min\": -1,\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(self, timeout=None, headers=None, **kwargs):\n        \"\"\"Initialize Enigma2 Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        try:\n            self.timeout = int(timeout)\n            if self.timeout < self.template_args[\"timeout\"][\"min\"]:\n                # Bulletproof; can't go lower then min value\n                self.timeout = self.template_args[\"timeout\"][\"min\"]\n\n        except (ValueError, TypeError):\n            # Use default timeout\n            self.timeout = self.template_args[\"timeout\"][\"default\"]\n\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"/\"\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n            self.fullpath.rstrip(\"/\"),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"timeout\": str(self.timeout),\n        }\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyEnigma2.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyEnigma2.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}{fullpath}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=NotifyEnigma2.quote(self.fullpath, safe=\"/\"),\n            params=NotifyEnigma2.urlencode(params),\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Enigma2 Notification.\"\"\"\n\n        # prepare Enigma2 Object\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        params = {\n            \"text\": body,\n            \"type\": MESSAGE_MAPPING.get(notify_type, Enigma2MessageType.INFO),\n            \"timeout\": self.timeout,\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        # Prepare our message URL\n        url += self.fullpath.rstrip(\"/\") + \"/api/message\"\n\n        self.logger.debug(\n            \"Enigma2 POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Enigma2 Parameters: {params!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.get(\n                url,\n                params=params,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyEnigma2.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Enigma2 notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            # We were able to post our message; now lets evaluate the response\n            try:\n                # Acquire our result\n                result = loads(r.content).get(\"result\", False)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n\n                # We could not parse JSON response.\n                result = False\n\n            if not result:\n                self.logger.warning(\n                    \"Failed to send Enigma2 notification: \"\n                    \"There was no server acknowledgement.\"\n                )\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n                # Return; we're done\n                return False\n\n            self.logger.info(\"Sent Enigma2 notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Enigma2 \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyEnigma2.unquote(x): NotifyEnigma2.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Save timeout value (if specified)\n        if \"timeout\" in results[\"qsd\"] and len(results[\"qsd\"][\"timeout\"]):\n            results[\"timeout\"] = results[\"qsd\"][\"timeout\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/fcm/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this plugin to work correct, the FCM server must be set up to allow\n# for remote connections.\n\n# Firebase Cloud Messaging\n# Visit your console page: https://console.firebase.google.com\n# 1. Create a project if you haven't already.  If you did the\n#    {project} ID will be listed as name-XXXXX.\n# 2. Click on your project from here to open it up.\n# 3. Access your Web API Key by clicking on:\n#     - The (gear-next-to-project-name) > Project Settings > Cloud Messaging\n\n# Visit the following site to get you're Project information:\n#    - https://console.cloud.google.com/project/_/settings/general/\n#\n# Docs: https://firebase.google.com/docs/cloud-messaging/send-message\n\n# Legacy Docs:\n# https://firebase.google.com/docs/cloud-messaging/http-server-ref\\\n#       #send-downstream\n#\n# If you Generate a new private key, it will provide a .json file\n# You will need this in order to send an apprise messag\nfrom json import dumps\n\nimport requests\n\nfrom ...apprise_attachment import AppriseAttachment\nfrom ...common import NotifyImageSize, NotifyType\nfrom ...locale import gettext_lazy as _\nfrom ...utils.logic import dict_full_update\nfrom ...utils.parse import parse_bool, parse_list, validate_regex\nfrom ..base import NotifyBase\nfrom .color import FCMColorManager\nfrom .common import FCM_MODES, FCMMode\nfrom .priority import FCM_PRIORITIES, FCMPriorityManager\n\n# Default our global support flag\nNOTIFY_FCM_SUPPORT_ENABLED = False\n\ntry:\n    from .oauth import GoogleOAuth\n\n    # We're good to go\n    NOTIFY_FCM_SUPPORT_ENABLED = True\n\nexcept ImportError:\n    # cryptography is the dependency of the .oauth library\n\n    # Create a dummy object for init() call to work\n    class GoogleOAuth:\n        pass\n\n\n# Our lookup map\nFCM_HTTP_ERROR_MAP = {\n    400: \"A bad request was made to the server.\",\n    401: \"The provided API Key was not valid.\",\n    404: \"The token could not be registered.\",\n}\n\n\nclass NotifyFCM(NotifyBase):\n    \"\"\"A wrapper for Google's Firebase Cloud Messaging Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_FCM_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"packages_required\": \"cryptography\"\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Firebase Cloud Messaging\"\n\n    # The services URL\n    service_url = \"https://firebase.google.com\"\n\n    # The default protocol\n    secure_protocol = \"fcm\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/fcm/\"\n\n    # Project Notification\n    # https://firebase.google.com/docs/cloud-messaging/send-message\n    notify_oauth2_url = (\n        \"https://fcm.googleapis.com/v1/projects/{project}/messages:send\"\n    )\n\n    notify_legacy_url = \"https://fcm.googleapis.com/fcm/send\"\n\n    # There is no reason we should exceed 5KB when reading in a JSON file.\n    # If it is more than this, then it is not accepted.\n    max_fcm_keyfile_size = 5000\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_256\n\n    # The maximum length of the body\n    body_maxlen = 1024\n\n    # Define object templates\n    templates = (\n        # OAuth2\n        \"{schema}://{project}/{targets}?keyfile={keyfile}\",\n        # Legacy Mode\n        \"{schema}://{apikey}/{targets}\",\n    )\n\n    # Define our template\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"keyfile\": {\n                \"name\": _(\"OAuth2 KeyFile\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"project\": {\n                \"name\": _(\"Project ID\"),\n                \"type\": \"string\",\n            },\n            \"target_device\": {\n                \"name\": _(\"Target Device\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_topic\": {\n                \"name\": _(\"Target Topic\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"mode\": {\n                \"name\": _(\"Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": FCM_MODES,\n                \"default\": FCMMode.Legacy,\n            },\n            \"priority\": {\n                \"name\": _(\"Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": FCM_PRIORITIES,\n            },\n            \"image_url\": {\n                \"name\": _(\"Custom Image URL\"),\n                \"type\": \"string\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            # Color can either be yes, no, or a #rrggbb (\n            # rrggbb without hashtag is accepted to)\n            \"color\": {\n                \"name\": _(\"Notification Color\"),\n                \"type\": \"string\",\n                \"default\": \"yes\",\n            },\n        },\n    )\n\n    # Define our data entry\n    template_kwargs = {\n        \"data_kwargs\": {\n            \"name\": _(\"Data Entries\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self,\n        project,\n        apikey,\n        targets=None,\n        mode=None,\n        keyfile=None,\n        data_kwargs=None,\n        image_url=None,\n        include_image=False,\n        color=None,\n        priority=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Firebase Cloud Messaging.\"\"\"\n        super().__init__(**kwargs)\n\n        if mode is None:\n            # Detect our mode\n            self.mode = FCMMode.OAuth2 if keyfile else FCMMode.Legacy\n\n        else:\n            # Setup our mode\n            self.mode = (\n                NotifyFCM.template_tokens[\"mode\"][\"default\"]\n                if not isinstance(mode, str)\n                else mode.lower()\n            )\n            if self.mode and self.mode not in FCM_MODES:\n                msg = f\"The FCM mode specified ({mode}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        # Used for Legacy Mode; this is the Web API Key retrieved from the\n        # User Panel\n        self.apikey = None\n\n        # Path to our Keyfile\n        self.keyfile = None\n\n        # Our Project ID is required to verify against the keyfile\n        # specified\n        self.project = None\n\n        # Initialize our Google OAuth module we can work with\n        self.oauth = GoogleOAuth(\n            user_agent=self.app_id,\n            timeout=self.request_timeout,\n            verify_certificate=self.verify_certificate,\n        )\n\n        if self.mode == FCMMode.OAuth2:\n            # The project ID associated with the account\n            self.project = validate_regex(project)\n            if not self.project:\n                msg = f\"An invalid FCM Project ID ({project}) was specified.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            if not keyfile:\n                msg = \"No FCM JSON KeyFile was specified.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            # Our keyfile object is just an AppriseAttachment object\n            self.keyfile = AppriseAttachment(asset=self.asset)\n            # Add our definition to our template\n            self.keyfile.add(keyfile)\n            # Enforce maximum file size\n            self.keyfile[0].max_file_size = self.max_fcm_keyfile_size\n\n        else:  # Legacy Mode\n\n            # The apikey associated with the account\n            self.apikey = validate_regex(apikey)\n            if not self.apikey:\n                msg = f\"An invalid FCM API key ({apikey}) was specified.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        # Acquire Device IDs to notify\n        self.targets = parse_list(targets)\n\n        # Our data Keyword/Arguments to include in our outbound payload\n        self.data_kwargs = {}\n        if isinstance(data_kwargs, dict):\n            self.data_kwargs.update(data_kwargs)\n\n        # Include the image as part of the payload\n        self.include_image = include_image\n\n        # A Custom Image URL\n        # FCM allows you to provide a remote https?:// URL to an image_url\n        # located on the internet that it will download and include in the\n        # payload.\n        #\n        # self.image_url() is reserved as an internal function name; so we\n        # jsut store it into a different variable for now\n        self.image_src = image_url\n\n        # Initialize our priority\n        self.priority = FCMPriorityManager(self.mode, priority)\n\n        # Initialize our color\n        self.color = FCMColorManager(color, asset=self.asset)\n        return\n\n    @property\n    def access_token(self):\n        \"\"\"Generates a access_token based on the keyfile provided.\"\"\"\n        keyfile = self.keyfile[0]\n        if not keyfile:\n            # We could not access the keyfile\n            self.logger.error(\n                f\"Could not access FCM keyfile {keyfile.url(privacy=True)}.\"\n            )\n            return None\n\n        if not self.oauth.load(keyfile.path):\n            self.logger.error(\n                f\"FCM keyfile {keyfile.url(privacy=True)} could not be loaded.\"\n            )\n            return None\n\n        # Verify our project id against the one provided in our keyfile\n        if self.project != self.oauth.project_id:\n            self.logger.error(\n                f\"FCM keyfile {keyfile.url(privacy=True)} identifies itself\"\n                \" for a different project\"\n            )\n            return None\n\n        # Return our generated key; the below returns None if a token could\n        # not be acquired\n        return self.oauth.access_token\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform FCM Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\"There are no FCM devices or topics to notify\")\n            return False\n\n        if self.mode == FCMMode.OAuth2:\n            access_token = self.access_token\n            if not access_token:\n                # Error message is generated in access_tokengen() so no reason\n                # to additionally write anything here\n                return False\n\n            headers = {\n                \"User-Agent\": self.app_id,\n                \"Content-Type\": \"application/json\",\n                \"Authorization\": f\"Bearer {access_token}\",\n            }\n\n            # Prepare our notify URL\n            notify_url = self.notify_oauth2_url\n\n        else:  # FCMMode.Legacy\n            headers = {\n                \"User-Agent\": self.app_id,\n                \"Content-Type\": \"application/json\",\n                \"Authorization\": f\"key={self.apikey}\",\n            }\n\n            # Prepare our notify URL\n            notify_url = self.notify_legacy_url\n\n        # Acquire image url\n        image = (\n            self.image_url(notify_type)\n            if not self.image_src\n            else self.image_src\n        )\n\n        has_error = False\n        # Create a copy of the targets list\n        targets = list(self.targets)\n        while len(targets):\n            recipient = targets.pop(0)\n\n            if self.mode == FCMMode.OAuth2:\n                payload = {\n                    \"message\": {\n                        \"token\": None,\n                        \"notification\": {\n                            \"title\": title,\n                            \"body\": body,\n                        },\n                    }\n                }\n\n                if self.color:\n                    # Acquire our color\n                    payload[\"message\"][\"android\"] = {\n                        \"notification\": {\"color\": self.color.get(notify_type)}\n                    }\n\n                if self.include_image and image:\n                    payload[\"message\"][\"notification\"][\"image\"] = image\n\n                if self.data_kwargs:\n                    payload[\"message\"][\"data\"] = self.data_kwargs\n\n                if recipient[0] == \"#\":\n                    payload[\"message\"][\"topic\"] = recipient[1:]\n                    self.logger.debug(\n                        \"FCM recipient %s parsed as a topic\", recipient[1:]\n                    )\n\n                else:\n                    payload[\"message\"][\"token\"] = recipient\n                    self.logger.debug(\n                        \"FCM recipient %s parsed as a device token\", recipient\n                    )\n\n            else:  # FCMMode.Legacy\n                payload = {\n                    \"notification\": {\n                        \"notification\": {\n                            \"title\": title,\n                            \"body\": body,\n                        }\n                    }\n                }\n\n                if self.color:\n                    # Acquire our color\n                    payload[\"notification\"][\"notification\"][\"color\"] = (\n                        self.color.get(notify_type)\n                    )\n\n                if self.include_image and image:\n                    payload[\"notification\"][\"notification\"][\"image\"] = image\n\n                if self.data_kwargs:\n                    payload[\"data\"] = self.data_kwargs\n\n                if recipient[0] == \"#\":\n                    payload[\"to\"] = f\"/topics/{recipient}\"\n                    self.logger.debug(\n                        \"FCM recipient %s parsed as a topic\", recipient[1:]\n                    )\n\n                else:\n                    payload[\"to\"] = recipient\n                    self.logger.debug(\n                        \"FCM recipient %s parsed as a device token\", recipient\n                    )\n\n            # A more advanced dict.update() that recursively includes\n            # sub-dictionaries as well\n            dict_full_update(payload, self.priority.payload())\n\n            self.logger.debug(\n                \"FCM %s POST URL: %s (cert_verify=%r)\",\n                self.mode,\n                notify_url,\n                self.verify_certificate,\n            )\n            self.logger.debug(\"FCM %s Payload: %s\", self.mode, payload)\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    notify_url.format(project=self.project),\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.no_content,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code, FCM_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send {} FCM notification: \"\n                        \"{}{}error={}.\".format(\n                            self.mode,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    has_error = True\n\n                else:\n                    self.logger.info(\"Sent %s FCM notification.\", self.mode)\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending FCM notification.\"\n                )\n                self.logger.debug(\"Socket Exception: %s\", e)\n\n                has_error = True\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.mode, self.apikey, self.project)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"mode\": self.mode,\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"color\": str(self.color),\n        }\n\n        if self.priority:\n            # Store our priority if one was defined\n            params[\"priority\"] = str(self.priority)\n\n        if self.keyfile:\n            # Include our keyfile if specified\n            params[\"keyfile\"] = NotifyFCM.quote(\n                self.keyfile[0].url(privacy=privacy), safe=\"\"\n            )\n\n        if self.image_src:\n            # Include our image path as part of our URL payload\n            params[\"image_url\"] = self.image_src\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Add our data keyword/args into our URL response\n        params.update({f\"+{k}\": v for k, v in self.data_kwargs.items()})\n\n        reference = (\n            NotifyFCM.quote(self.project)\n            if self.mode == FCMMode.OAuth2\n            else self.pprint(self.apikey, privacy, safe=\"\")\n        )\n\n        return \"{schema}://{reference}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            reference=reference,\n            targets=\"/\".join([NotifyFCM.quote(x) for x in self.targets]),\n            params=NotifyFCM.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The apikey/project is stored in the hostname\n        results[\"apikey\"] = NotifyFCM.unquote(results[\"host\"])\n        results[\"project\"] = results[\"apikey\"]\n\n        # Get our Device IDs\n        results[\"targets\"] = NotifyFCM.split_path(results[\"fullpath\"])\n\n        # Get our mode\n        results[\"mode\"] = results[\"qsd\"].get(\"mode\")\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyFCM.parse_list(results[\"qsd\"][\"to\"])\n\n        # Our Project ID\n        if \"project\" in results[\"qsd\"] and results[\"qsd\"][\"project\"]:\n            results[\"project\"] = NotifyFCM.unquote(results[\"qsd\"][\"project\"])\n\n        # Our Web API Key\n        if \"apikey\" in results[\"qsd\"] and results[\"qsd\"][\"apikey\"]:\n            results[\"apikey\"] = NotifyFCM.unquote(results[\"qsd\"][\"apikey\"])\n\n        # Our Keyfile (JSON)\n        if \"keyfile\" in results[\"qsd\"] and results[\"qsd\"][\"keyfile\"]:\n            results[\"keyfile\"] = NotifyFCM.unquote(results[\"qsd\"][\"keyfile\"])\n\n        # Our Priority\n        if \"priority\" in results[\"qsd\"] and results[\"qsd\"][\"priority\"]:\n            results[\"priority\"] = NotifyFCM.unquote(results[\"qsd\"][\"priority\"])\n\n        # Our Color\n        if \"color\" in results[\"qsd\"] and results[\"qsd\"][\"color\"]:\n            results[\"color\"] = NotifyFCM.unquote(results[\"qsd\"][\"color\"])\n\n        # Boolean to include an image or not\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyFCM.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # Extract image_url if it was specified\n        if \"image_url\" in results[\"qsd\"]:\n            results[\"image_url\"] = NotifyFCM.unquote(\n                results[\"qsd\"][\"image_url\"]\n            )\n            if \"image\" not in results[\"qsd\"]:\n                # Toggle default behaviour if a custom image was provided\n                # but ONLY if the `image` boolean was not set\n                results[\"include_image\"] = True\n\n        # Store our data keyword/args if specified\n        results[\"data_kwargs\"] = results[\"qsd+\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/fcm/color.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# New priorities are defined here:\n# - https://firebase.google.com/docs/reference/fcm/rest/v1/\\\n#       projects.messages#NotificationPriority\n\n# Legacy color payload example here:\n# https://firebase.google.com/docs/reference/fcm/rest/v1/\\\n#       projects.messages#androidnotification\nimport re\n\nfrom ...asset import AppriseAsset\nfrom ...common import NotifyType\nfrom ...utils.parse import parse_bool\n\n\nclass FCMColorManager:\n    \"\"\"A Simple object to accept either a boolean value.\n\n    - True: Use colors provided by Apprise\n    - False: Do not use colors at all\n    - rrggbb: where you provide the rgb values (hence #333333)\n    - rgb: is also accepted as rgb values (hence #333)\n\n    For RGB colors, the hashtag is optional\n    \"\"\"\n\n    __color_rgb = re.compile(\n        r\"#?((?P<r1>[0-9A-F]{2})(?P<g1>[0-9A-F]{2})(?P<b1>[0-9A-F]{2})\"\n        r\"|(?P<r2>[0-9A-F])(?P<g2>[0-9A-F])(?P<b2>[0-9A-F]))\",\n        re.IGNORECASE,\n    )\n\n    def __init__(self, color, asset=None):\n        \"\"\"Parses the color object accordingly.\"\"\"\n\n        # Initialize an asset object if one isn't otherwise defined\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        # Prepare our color\n        self.color = color\n        if isinstance(color, str):\n            self.color = self.__color_rgb.match(color)\n            if self.color:\n                # Store our RGB value as #rrggbb\n                self.color = (\n                    \"{red}{green}{blue}\".format(\n                        red=self.color.group(\"r1\"),\n                        green=self.color.group(\"g1\"),\n                        blue=self.color.group(\"b1\"),\n                    ).lower()\n                    if self.color.group(\"r1\")\n                    else \"{red1}{red2}{green1}{green2}{blue1}{blue2}\".format(\n                        red1=self.color.group(\"r2\"),\n                        red2=self.color.group(\"r2\"),\n                        green1=self.color.group(\"g2\"),\n                        green2=self.color.group(\"g2\"),\n                        blue1=self.color.group(\"b2\"),\n                        blue2=self.color.group(\"b2\"),\n                    ).lower()\n                )\n\n        if self.color is None:\n            # Color not determined, so base it on boolean parser\n            self.color = parse_bool(color)\n\n    def get(self, notify_type=NotifyType.INFO):\n        \"\"\"Returns color or true/false value based on configuration.\"\"\"\n\n        if isinstance(self.color, bool) and self.color:\n            # We want to use the asset value\n            return self.asset.color(notify_type=notify_type)\n\n        elif self.color:\n            # return our color as is\n            return \"#\" + self.color\n\n        # No color to return\n        return None\n\n    def __str__(self):\n        \"\"\"Our color representation.\"\"\"\n        if isinstance(self.color, bool):\n            return \"yes\" if self.color else \"no\"\n\n        # otherwise return our color\n        return self.color\n\n    def __bool__(self):\n        \"\"\"Allows this object to be wrapped in an 'if statement'.\n\n        True is returned if a color was loaded\n        \"\"\"\n        return bool(self.color is True or isinstance(self.color, str))\n"
  },
  {
    "path": "apprise/plugins/fcm/common.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\nclass FCMMode:\n    \"\"\"Define the Firebase Cloud Messaging Modes.\"\"\"\n\n    # The legacy way of sending a message\n    Legacy = \"legacy\"\n\n    # The new API\n    OAuth2 = \"oauth2\"\n\n\n# FCM Modes\nFCM_MODES = (\n    # Legacy API\n    FCMMode.Legacy,\n    # HTTP v1 URL\n    FCMMode.OAuth2,\n)\n"
  },
  {
    "path": "apprise/plugins/fcm/oauth.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# To generate a private key file for your service account:\n#\n#  1. In the Firebase console, open Settings > Service Accounts.\n#  2. Click Generate New Private Key, then confirm by clicking Generate Key.\n#  3. Securely store the JSON file containing the key.\n\nimport base64\nimport calendar\nfrom datetime import datetime, timedelta, timezone\nimport json\nfrom json.decoder import JSONDecodeError\nfrom urllib.parse import urlencode as _urlencode\n\nfrom cryptography.exceptions import UnsupportedAlgorithm\nfrom cryptography.hazmat import backends\nfrom cryptography.hazmat.primitives import asymmetric, hashes, serialization\nimport requests\n\nfrom ...logger import logger\n\n\nclass GoogleOAuth:\n    \"\"\"A OAuth simplified implimentation to Google's Firebase Cloud\n    Messaging.\"\"\"\n\n    scopes = [\n        \"https://www.googleapis.com/auth/firebase.messaging\",\n    ]\n\n    # 1 hour in seconds (the lifetime of our token)\n    access_token_lifetime_sec = timedelta(seconds=3600)\n\n    # The default URI to use if one is not found\n    default_token_uri = \"https://oauth2.googleapis.com/token\"\n\n    # Taken right from google.auth.helpers:\n    clock_skew = timedelta(seconds=10)\n\n    def __init__(\n        self, user_agent=None, timeout=(5, 4), verify_certificate=True\n    ):\n        \"\"\"Initialize our OAuth object.\"\"\"\n\n        # Wether or not to verify ssl\n        self.verify_certificate = verify_certificate\n\n        # Our (connect, read) timeout\n        self.request_timeout = timeout\n\n        # assign our user-agent if defined\n        self.user_agent = user_agent\n\n        # initialize our other object variables\n        self.__reset()\n\n    def __reset(self):\n        \"\"\"Reset object internal variables.\"\"\"\n\n        # Google Keyfile Encoding\n        self.encoding = \"utf-8\"\n\n        # Our retrieved JSON content (unmangled)\n        self.content = None\n\n        # Our generated key information we cache once loaded\n        self.private_key = None\n\n        # Our keys we build using the provided content\n        self.__refresh_token = None\n        self.__access_token = None\n        self.__access_token_expiry = datetime.now(timezone.utc)\n\n    def load(self, path):\n        \"\"\"Generate our SSL details.\"\"\"\n\n        # Reset our objects\n        self.content = None\n        self.private_key = None\n        self.__access_token = None\n        self.__access_token_expiry = datetime.now(timezone.utc)\n\n        try:\n            with open(path, encoding=self.encoding) as fp:\n                self.content = json.loads(fp.read())\n\n        except OSError:\n            logger.debug(f\"FCM keyfile {path} could not be accessed\")\n            return False\n\n        except JSONDecodeError as e:\n            logger.debug(\n                f\"FCM keyfile {path} generated a JSONDecodeError: {e}\"\n            )\n            return False\n\n        if not isinstance(self.content, dict):\n            logger.debug(f\"FCM keyfile {path} is incorrectly structured\")\n            self.__reset()\n            return False\n\n        # Verify we've got the correct tokens in our content to work with\n        is_valid = next(\n            (\n                False\n                for k in (\n                    \"client_email\",\n                    \"private_key_id\",\n                    \"private_key\",\n                    \"type\",\n                    \"project_id\",\n                )\n                if not self.content.get(k)\n            ),\n            True,\n        )\n\n        if not is_valid:\n            logger.debug(f\"FCM keyfile {path} is missing required information\")\n            self.__reset()\n            return False\n\n        # Verify our service_account type\n        if self.content.get(\"type\") != \"service_account\":\n            logger.debug(f\"FCM keyfile {path} is not of type service_account\")\n            self.__reset()\n            return False\n\n        # Prepare our private key which is in PKCS8 PEM format\n        try:\n            self.private_key = serialization.load_pem_private_key(\n                self.content.get(\"private_key\").encode(self.encoding),\n                password=None,\n                backend=backends.default_backend(),\n            )\n\n        except (TypeError, ValueError):\n            # ValueError: If the PEM data could not be decrypted or if its\n            #             structure could not be decoded successfully.\n            # TypeError:  If a password was given and the private key was\n            #             not encrypted. Or if the key was encrypted but\n            #             no password was supplied.\n            logger.error(\"FCM provided private key is invalid.\")\n            self.__reset()\n            return False\n\n        except UnsupportedAlgorithm:\n            # If the serialized key is of a type that is not supported by\n            # the backend.\n            logger.error(\"FCM provided private key is not supported\")\n            self.__reset()\n            return False\n\n        # We've done enough validation to move on\n        return True\n\n    @property\n    def access_token(self):\n        \"\"\"Returns our access token (if it hasn't expired yet)\n\n        - if we do not have one we'll fetch one.\n        - if it expired, we'll renew it\n        - if a key simply can't be acquired, then we return None\n        \"\"\"\n\n        if not self.private_key or not self.content:\n            # invalid content (or not loaded)\n            logger.error(\n                \"No FCM JSON keyfile content loaded to generate a access \"\n                \"token with.\"\n            )\n            return None\n\n        if self.__access_token_expiry > datetime.now(timezone.utc):\n            # Return our no-expired key\n            return self.__access_token\n\n        # If we reach here we need to prepare our payload\n        token_uri = self.content.get(\"token_uri\", self.default_token_uri)\n        service_email = self.content.get(\"client_email\")\n        key_identifier = self.content.get(\"private_key_id\")\n\n        # Generate our Assertion\n        now = datetime.now(timezone.utc)\n        expiry = now + self.access_token_lifetime_sec\n\n        payload = {\n            # The number of seconds since the UNIX epoch.\n            \"iat\": calendar.timegm(now.utctimetuple()),\n            \"exp\": calendar.timegm(expiry.utctimetuple()),\n            # The issuer must be the service account email.\n            \"iss\": service_email,\n            # The audience must be the auth token endpoint's URI\n            \"aud\": token_uri,\n            # Our token scopes\n            \"scope\": \" \".join(self.scopes),\n        }\n\n        # JWT Details\n        header = {\n            \"typ\": \"JWT\",\n            \"alg\": (\n                \"RS256\"\n                if isinstance(self.private_key, asymmetric.rsa.RSAPrivateKey)\n                else \"ES256\"\n            ),\n            # Key Identifier\n            \"kid\": key_identifier,\n        }\n\n        # Encodes base64 strings removing any padding characters.\n        segments = [\n            base64.urlsafe_b64encode(\n                json.dumps(header).encode(self.encoding)\n            ).rstrip(b\"=\"),\n            base64.urlsafe_b64encode(\n                json.dumps(payload).encode(self.encoding)\n            ).rstrip(b\"=\"),\n        ]\n\n        signing_input = b\".\".join(segments)\n        signature = self.private_key.sign(\n            signing_input,\n            asymmetric.padding.PKCS1v15(),\n            hashes.SHA256(),\n        )\n\n        # Finally append our segment\n        segments.append(base64.urlsafe_b64encode(signature).rstrip(b\"=\"))\n        assertion = b\".\".join(segments)\n\n        http_payload = _urlencode({\n            \"assertion\": assertion,\n            \"grant_type\": \"urn:ietf:params:oauth:grant-type:jwt-bearer\",\n        })\n\n        http_headers = {\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n        if self.user_agent:\n            http_headers[\"User-Agent\"] = self.user_agent\n\n        logger.info(\"Refreshing FCM Access Token\")\n        try:\n            r = requests.post(\n                token_uri,\n                data=http_payload,\n                headers=http_headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                logger.warning(\n                    f\"Failed to update FCM Access Token error={r.status_code}.\"\n                )\n\n                logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n                return None\n\n        except requests.RequestException as e:\n            logger.warning(\n                \"A Connection error occurred refreshing FCM Access Token.\"\n            )\n            logger.debug(\"Socket Exception: %s\", str(e))\n            return None\n\n        # If we get here, we made our request successfully, now we need\n        # to parse out the data\n        response = json.loads(r.content)\n        self.__access_token = response[\"access_token\"]\n        self.__refresh_token = response.get(\n            \"refresh_token\", self.__refresh_token\n        )\n\n        if \"expires_in\" in response:\n            delta = timedelta(seconds=int(response[\"expires_in\"]))\n            self.__access_token_expiry = (\n                delta + datetime.now(timezone.utc) - self.clock_skew\n            )\n\n        else:\n            # Allow some grace before we expire\n            self.__access_token_expiry = expiry - self.clock_skew\n\n        logger.debug(\n            \"Access Token successfully acquired: %s\", self.__access_token\n        )\n\n        # Return our token\n        return self.__access_token\n\n    @property\n    def project_id(self):\n        \"\"\"Returns the project id found in the file.\"\"\"\n        return None if not self.content else self.content.get(\"project_id\")\n"
  },
  {
    "path": "apprise/plugins/fcm/priority.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# New priorities are defined here:\n# - https://firebase.google.com/docs/reference/fcm/rest/v1/\\\n#       projects.messages#NotificationPriority\n\n# Legacy priorities are defined here:\n# - https://firebase.google.com/docs/cloud-messaging/http-server-ref\nfrom ...logger import logger\nfrom .common import FCM_MODES, FCMMode\n\n\nclass NotificationPriority:\n    \"\"\"\n    Defines the Notification Priorities as described on:\n    https://firebase.google.com/docs/reference/fcm/rest/v1/\\\n            projects.messages#androidmessagepriority\n\n        NORMAL:\n            Default priority for data messages. Normal priority messages won't\n            open network connections on a sleeping device, and their delivery\n            may be delayed to conserve the battery. For less time-sensitive\n            messages, such as notifications of new email or other data to sync,\n            choose normal delivery priority.\n\n        HIGH:\n            Default priority for notification messages. FCM attempts to\n            deliver high priority messages immediately, allowing the FCM\n            service to wake a sleeping device when possible and open a network\n            connection to your app server. Apps with instant messaging, chat,\n            or voice call alerts, for example, generally need to open a\n            network connection and make sure FCM delivers the message to the\n            device without delay. Set high priority if the message is\n            time-critical and requires the user's immediate interaction, but\n            beware that setting your messages to high priority contributes\n            more to battery drain compared with normal priority messages.\n    \"\"\"\n\n    NORMAL = \"NORMAL\"\n    HIGH = \"HIGH\"\n\n\nclass FCMPriority:\n    \"\"\"Defines our accepted priorites.\"\"\"\n\n    MIN = \"min\"\n\n    LOW = \"low\"\n\n    NORMAL = \"normal\"\n\n    HIGH = \"high\"\n\n    MAX = \"max\"\n\n\nFCM_PRIORITIES = (\n    FCMPriority.MIN,\n    FCMPriority.LOW,\n    FCMPriority.NORMAL,\n    FCMPriority.HIGH,\n    FCMPriority.MAX,\n)\n\n\nclass FCMPriorityManager:\n    \"\"\"A Simple object to make it easier to work with FCM set priorities.\"\"\"\n\n    priority_map = {\n        FCMPriority.MIN: {\n            FCMMode.OAuth2: {\n                \"message\": {\n                    \"android\": {\"priority\": NotificationPriority.NORMAL},\n                    \"apns\": {\"headers\": {\"apns-priority\": \"5\"}},\n                    \"webpush\": {\"headers\": {\"Urgency\": \"very-low\"}},\n                }\n            },\n            FCMMode.Legacy: {\n                \"priority\": \"normal\",\n            },\n        },\n        FCMPriority.LOW: {\n            FCMMode.OAuth2: {\n                \"message\": {\n                    \"android\": {\"priority\": NotificationPriority.NORMAL},\n                    \"apns\": {\"headers\": {\"apns-priority\": \"5\"}},\n                    \"webpush\": {\"headers\": {\"Urgency\": \"low\"}},\n                }\n            },\n            FCMMode.Legacy: {\n                \"priority\": \"normal\",\n            },\n        },\n        FCMPriority.NORMAL: {\n            FCMMode.OAuth2: {\n                \"message\": {\n                    \"android\": {\"priority\": NotificationPriority.NORMAL},\n                    \"apns\": {\"headers\": {\"apns-priority\": \"5\"}},\n                    \"webpush\": {\"headers\": {\"Urgency\": \"normal\"}},\n                }\n            },\n            FCMMode.Legacy: {\n                \"priority\": \"normal\",\n            },\n        },\n        FCMPriority.HIGH: {\n            FCMMode.OAuth2: {\n                \"message\": {\n                    \"android\": {\"priority\": NotificationPriority.HIGH},\n                    \"apns\": {\"headers\": {\"apns-priority\": \"10\"}},\n                    \"webpush\": {\"headers\": {\"Urgency\": \"high\"}},\n                }\n            },\n            FCMMode.Legacy: {\n                \"priority\": \"high\",\n            },\n        },\n        FCMPriority.MAX: {\n            FCMMode.OAuth2: {\n                \"message\": {\n                    \"android\": {\"priority\": NotificationPriority.HIGH},\n                    \"apns\": {\"headers\": {\"apns-priority\": \"10\"}},\n                    \"webpush\": {\"headers\": {\"Urgency\": \"high\"}},\n                }\n            },\n            FCMMode.Legacy: {\n                \"priority\": \"high\",\n            },\n        },\n    }\n\n    def __init__(self, mode, priority=None):\n        \"\"\"Takes a FCMMode and Priority.\"\"\"\n\n        self.mode = mode\n        if self.mode not in FCM_MODES:\n            msg = f\"The FCM mode specified ({mode}) is invalid.\"\n            logger.warning(msg)\n            raise TypeError(msg)\n\n        self.priority = None\n        if priority:\n            self.priority = next(\n                (\n                    p\n                    for p in FCM_PRIORITIES\n                    if p.startswith(priority[:2].lower())\n                ),\n                None,\n            )\n            if not self.priority:\n                msg = f\"An invalid FCM Priority ({priority}) was specified.\"\n                logger.warning(msg)\n                raise TypeError(msg)\n\n    def payload(self):\n        \"\"\"Returns our payload depending on our mode.\"\"\"\n        return (\n            self.priority_map[self.priority][self.mode]\n            if self.priority\n            else {}\n        )\n\n    def __str__(self):\n        \"\"\"Our priority representation.\"\"\"\n        return self.priority if self.priority else \"\"\n\n    def __bool__(self):\n        \"\"\"Allows this object to be wrapped in an 'if statement'.\n\n        True is returned if a priority was loaded\n        \"\"\"\n        return bool(self.priority)\n"
  },
  {
    "path": "apprise/plugins/feishu.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Feishu\n#   1. Visit https://open.feishu.cn\n\n# Custom Bot Setup\n#    https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot\n#\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyFeishu(NotifyBase):\n    \"\"\"A wrapper for Feishu Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Feishu\")\n\n    # The services URL\n    service_url = \"https://open.feishu.cn/\"\n\n    # The default secure protocol\n    secure_protocol = \"feishu\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/feishu/\"\n\n    # Notification URL\n    notify_url = \"https://open.feishu.cn/open-apis/bot/v2/hook/{token}/\"\n\n    # Define object templates\n    templates = (\"{schema}://{token}\",)\n\n    # The title is not used\n    title_maxlen = 0\n\n    # Limit is documented to be 20K message sizes.  This number safely\n    # allows padding around that size.\n    body_maxlen = 19985\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize Feishu Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Feishu token specified ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send our notification.\"\"\"\n\n        # prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Our Message\n        payload = {\n            \"msg_type\": \"text\",\n            \"content\": {\n                \"text\": body,\n            },\n        }\n\n        self.logger.debug(\n            \"Feishu GET URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Feishu Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url.format(token=self.token),\n                data=dumps(payload).encode(\"utf-8\"),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            #\n            # Sample Responses\n            #\n            # Valid:\n            # {\n            #   \"code\": 0,\n            #   \"data\": {},\n            #   \"msg\": \"success\"\n            # }\n\n            # Invalid (non 200 response):\n            # {\n            #   \"code\": 9499,\n            #   \"msg\": \"Bad Request\",\n            #   \"data\": {}\n            # }\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyFeishu.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Feishu notification: {}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Feishu notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Feishu notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Prepare our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{token}/?{params}\".format(\n            schema=self.secure_protocol,\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            params=NotifyFeishu.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        # parse_url already handles getting the `user` and `password` fields\n        # populated.\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Allow over-ride\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyFeishu.unquote(results[\"qsd\"][\"token\"])\n\n        else:\n            results[\"token\"] = NotifyFeishu.unquote(results[\"host\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/flock.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you need to first access https://dev.flock.com/webhooks\n# Specifically https://dev.flock.com/webhooks/incoming\n#\n# To create a new incoming webhook for your account. You'll need to\n# follow the wizard to pre-determine the channel(s) you want your\n# message to broadcast to. When you've completed this, you will\n# recieve a URL that looks something like this:\n# https://api.flock.com/hooks/sendMessage/134b8gh0-eba0-4fa9-ab9c-257ced0e8221\n#                                                             ^\n#                                                             |\n#  This is important <----------------------------------------^\n#\n#  It becomes your 'token' that you will pass into this class\n#\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nFLOCK_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n# Used to detect a channel/user\nIS_CHANNEL_RE = re.compile(r\"^(#|g:)(?P<id>[A-Z0-9_]+)$\", re.I)\nIS_USER_RE = re.compile(r\"^(@|u:)?(?P<id>[A-Z0-9_]+)$\", re.I)\n\n\nclass NotifyFlock(NotifyBase):\n    \"\"\"A wrapper for Flock Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Flock\"\n\n    # The services URL\n    service_url = \"https://flock.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"flock\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/flock/\"\n\n    # Flock uses the http protocol with JSON requests\n    notify_url = \"https://api.flock.com/hooks/sendMessage\"\n\n    # API Wrapper\n    notify_api = \"https://api.flock.co/v1/chat.sendMessage\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # Define object templates\n    templates = (\n        \"{schema}://{token}\",\n        \"{schema}://{botname}@{token}\",\n        \"{schema}://{botname}@{token}/{targets}\",\n        \"{schema}://{token}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Access Key\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z0-9-]+$\", \"i\"),\n                \"private\": True,\n                \"required\": True,\n            },\n            \"botname\": {\n                \"name\": _(\"Bot Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"user\",\n            },\n            \"to_user\": {\n                \"name\": _(\"To User ID\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"regex\": (r\"^[A-Z0-9_]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"to_channel\": {\n                \"name\": _(\"To Channel ID\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"regex\": (r\"^[A-Z0-9_]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(self, token, targets=None, include_image=True, **kwargs):\n        \"\"\"Initialize Flock Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Build ourselves a target list\n        self.targets = []\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"An invalid Flock Access Key ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Track whether or not we want to send an image with our notification\n        # or not.\n        self.include_image = include_image\n\n        # Track any issues\n        has_error = False\n\n        # Tidy our targets\n        targets = parse_list(targets)\n\n        for target in targets:\n            result = IS_USER_RE.match(target)\n            if result:\n                self.targets.append(\"u:\" + result.group(\"id\"))\n                continue\n\n            result = IS_CHANNEL_RE.match(target)\n            if result:\n                self.targets.append(\"g:\" + result.group(\"id\"))\n                continue\n\n            has_error = True\n            self.logger.warning(\n                f\"Ignoring invalid target ({target}) specified.\"\n            )\n\n        if has_error and not self.targets:\n            # We have a bot token and no target(s) to message\n            msg = \"No Flock targets to notify.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Flock Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        if self.notify_format == NotifyFormat.HTML:\n            body = f\"<flockml>{body}</flockml>\"\n\n        else:\n            title = NotifyFlock.escape_html(title, whitespace=False)\n            body = NotifyFlock.escape_html(body, whitespace=False)\n\n            body = \"<flockml>{}{}</flockml>\".format(\n                \"\" if not title else f\"<b>{title}</b><br/>\", body\n            )\n\n        payload = {\n            \"token\": self.token,\n            \"flockml\": body,\n            \"sendAs\": {\n                \"name\": self.app_id if not self.user else self.user,\n                # A Profile Image is only configured if we're configured to\n                # allow it\n                \"profileImage\": (\n                    None\n                    if not self.include_image\n                    else self.image_url(notify_type)\n                ),\n            },\n        }\n\n        if len(self.targets):\n            # Create a copy of our targets\n            targets = list(self.targets)\n\n            while len(targets) > 0:\n                # Get our first item\n                target = targets.pop(0)\n\n                # Copy and update our payload\n                payload_ = payload.copy()\n                payload_[\"to\"] = target\n\n                if not self._post(self.notify_api, headers, payload_):\n                    has_error = True\n\n        else:\n            # Webhook\n            url = f\"{self.notify_url}/{self.token}\"\n            if not self._post(url, headers, payload):\n                has_error = True\n\n        return not has_error\n\n    def _post(self, url, headers, payload):\n        \"\"\"A wrapper to the requests object.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        self.logger.debug(\n            f\"Flock POST URL: {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Flock Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyFlock.http_response_code_lookup(\n                    r.status_code, FLOCK_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send Flock notification : {}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                has_error = True\n\n            else:\n                self.logger.info(\"Sent Flock notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Flock notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            has_error = True\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{token}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyFlock.quote(target, safe=\"\") for target in self.targets]\n            ),\n            params=NotifyFlock.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyFlock.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyFlock.parse_list(results[\"qsd\"][\"to\"])\n\n        # The first token is stored in the hostname\n        results[\"token\"] = NotifyFlock.unquote(results[\"host\"])\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://api.flock.com/hooks/sendMessage/TOKEN\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://api\\.flock\\.com/hooks/sendMessage/\"\n            r\"(?P<token>[a-z0-9-]{24})/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyFlock.parse_url(\n                \"{schema}://{token}/{params}\".format(\n                    schema=NotifyFlock.secure_protocol,\n                    token=result.group(\"token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/fluxer.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this to work correctly you need to create a webhook. To do this just\n# click on the little gear icon next to the channel you're part of. From\n# here you'll be able to access the Webhooks menu and create a new one.\n#\n#  When you've completed, you'll get a URL that looks a little like this:\n#  https://api.fluxer.app/webhooks/417429632418316298/\\\n#         JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV\n#\n#  Simplified, it looks like this:\n#     https://api.fluxer.app/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n#  or:\n#     https://api.fluxer.app/v1/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n#\n#  This plugin will simply work using the url of:\n#     fluxer://WEBHOOK_ID/WEBHOOK_TOKEN\n#\nfrom __future__ import annotations\n\nimport contextlib\nfrom datetime import datetime, timedelta, timezone\nfrom itertools import chain\nfrom json import dumps\nimport re\nfrom typing import Any\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_hostname,\n    is_ipaddr,\n    parse_bool,\n    parse_list,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n# Used to detect user/role IDs and @here/@everyone tokens.\nUSER_ROLE_DETECTION_RE = re.compile(\n    r\"\\s*(?:<?@(?P<role>&?)(?P<id>[0-9]+)>?|@(?P<value>[a-z0-9]+))\",\n    re.I,\n)\n\n\nclass FluxerMode:\n    \"\"\"Define Fluxer Notification Modes.\"\"\"\n\n    # App posts upstream to the developer API on Fluxer's website\n    CLOUD = \"cloud\"\n\n    # Running a dedicated private Fluxer Server\n    PRIVATE = \"private\"\n\n\nFLUXER_MODES = (\n    FluxerMode.CLOUD,\n    FluxerMode.PRIVATE,\n)\n\n\nclass NotifyFluxer(NotifyBase):\n    \"\"\"A wrapper for Fluxer Webhook Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Fluxer\"\n\n    # The Services URL\n    service_url = \"https://fluxer.app/\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/fluxer/\"\n\n    # The default protocol\n    protocol = \"fluxer\"\n\n    # The default secure protocol\n    secure_protocol = \"fluxers\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_256\n\n    # Discord is kind enough to return how many more requests we're allowed to\n    # continue to make within it's header response as:\n    # Retry-After: number of seconds to try again\n    request_rate_per_sec = 0\n\n    # Support attachments\n    attachment_support = True\n\n    # Maximum number of attachments allowed per message\n    fluxer_max_files = 10\n\n    # The default period of time to wait if we can not determine the reason\n    # for the 429 (to many) request\n    default_delay_sec = 1.0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 2000\n\n    # The 2000 characters above defined by the body_maxlen include that of the\n    # title. Setting this to True ensures overflow options behave properly\n    overflow_amalgamate_title = True\n\n    # Fluxer limit for number of embed fields per message\n    fluxer_max_fields = 10\n\n    # If our hostname matches the following we automatically enforce cloud\n    # mode\n    __auto_cloud_host = re.compile(r\"fluxer\\.app\", re.IGNORECASE)\n\n    # Default upstream/cloud host if none is defined\n    cloud_notify_host = \"https://api.fluxer.app\"\n\n    # Webhook URLs used by the Fluxer API.\n    notify_url = \"{prefix}/webhooks/{webhook_id}/{webhook_token}\"\n\n    templates = (\n        \"{schema}://{webhook_id}/{webhook_token}\",\n        \"{schema}://{host}/{webhook_id}/{webhook_token}\",\n        \"{schema}://{host}:{port}/{webhook_id}/{webhook_token}\",\n        \"{schema}://{botname}@{webhook_id}/{webhook_token}\",\n        \"{schema}://{botname}@{host}:{port}/{webhook_id}/{webhook_token}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"botname\": {\n                \"name\": _(\"Bot Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"user\",\n            },\n            \"webhook_id\": {\n                \"name\": _(\"Webhook ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[0-9]{10,}$\", \"i\"),\n            },\n            \"webhook_token\": {\n                \"name\": _(\"Webhook Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Za-z0-9_\\-]{16,}$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"mode\": {\n                \"name\": _(\"Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": FLUXER_MODES,\n                \"default\": FluxerMode.CLOUD,\n            },\n            \"tts\": {\n                \"name\": _(\"Text To Speech\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"avatar\": {\n                \"name\": _(\"Avatar Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"avatar_url\": {\n                \"name\": _(\"Avatar URL\"),\n                \"type\": \"string\",\n            },\n            \"href\": {\n                \"name\": _(\"URL\"),\n                \"type\": \"string\",\n            },\n            \"url\": {\n                \"alias_of\": \"href\",\n            },\n            \"thread\": {\n                \"name\": _(\"Thread ID\"),\n                \"type\": \"string\",\n            },\n            \"thread_name\": {\n                \"name\": _(\"Thread Name\"),\n                \"type\": \"string\",\n            },\n            \"footer\": {\n                \"name\": _(\"Display Footer\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"footer_logo\": {\n                \"name\": _(\"Footer Logo\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"fields\": {\n                \"name\": _(\"Use Fields\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"flags\": {\n                \"name\": _(\"Discord Flags\"),\n                \"type\": \"int\",\n                \"min\": 0,\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            \"ping\": {\n                \"name\": _(\"Ping Users/Roles\"),\n                \"type\": \"list:string\",\n            },\n            \"name\": {\n                \"alias_of\": \"botname\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        webhook_id: str,\n        webhook_token: str,\n        mode: str | None = None,\n        tts: bool = False,\n        avatar: bool = True,\n        footer: bool = False,\n        footer_logo: bool = True,\n        include_image: bool = False,\n        fields: bool = True,\n        avatar_url: str | None = None,\n        href: str | None = None,\n        thread: str | None = None,\n        thread_name: str | None = None,\n        flags: int | None = None,\n        ping: list[str] | None = None,\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"Initialize Fluxer Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        # Webhook ID (associated with project)\n        self.webhook_id = validate_regex(\n            webhook_id, *self.template_tokens[\"webhook_id\"][\"regex\"]\n        )\n        if not self.webhook_id:\n            msg = f\"An invalid Fluxer Webhook ID ({webhook_id}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Webhook Token (associated with project)\n        self.webhook_token = validate_regex(\n            webhook_token, *self.template_tokens[\"webhook_token\"][\"regex\"]\n        )\n        if not self.webhook_token:\n            msg = (\n                \"An invalid Fluxer Webhook Token \"\n                f\"({webhook_token}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prepare our mode\n        self.mode = (\n            mode.strip().lower()\n            if isinstance(mode, str)\n            else self.template_args[\"mode\"][\"default\"]\n        )\n\n        if self.mode not in FLUXER_MODES:\n            msg = f\"An invalid Fluxer Mode ({mode}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if not self.host and self.mode == FluxerMode.PRIVATE:\n            # No host provided\n            msg = f\"An invalid Fluxer Hostname ({self.host}) was provided.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if self.mode == FluxerMode.PRIVATE and \\\n                self.__auto_cloud_host.search(self.host):\n            # Is a Fluxer Cloud API\n            self.mode = FluxerMode.CLOUD\n            self.logger.warning(\n                \"Fluxer mode changed to %s mode because fluxer.app found \"\n                \"in %s\",\n                self.mode,\n                self.host,\n            )\n\n        # Text To Speech\n        self.tts = tts if isinstance(tts, bool) \\\n            else parse_bool(tts, self.template_args[\"tts\"][\"default\"])\n\n        # Avatar\n        self.avatar = avatar if isinstance(avatar, bool) \\\n            else parse_bool(avatar, self.template_args[\"avatar\"][\"default\"])\n\n        # Footer\n        self.footer = footer if isinstance(footer, bool) \\\n            else parse_bool(footer, self.template_args[\"footer\"][\"default\"])\n\n        # Footer Logo\n        self.footer_logo = footer_logo if isinstance(footer_logo, bool) \\\n            else parse_bool(\n                footer_logo, self.template_args[\"footer_logo\"][\"default\"])\n\n        # Include Image\n        self.include_image = include_image if isinstance(include_image, bool) \\\n            else parse_bool(\n                include_image, self.template_args[\"image\"][\"default\"])\n\n        # Fields\n        self.fields = fields if isinstance(fields, bool) \\\n            else parse_bool(fields, self.template_args[\"fields\"][\"default\"])\n\n        self.thread_id = thread\n        self.thread_name = thread_name\n\n        self.avatar_url = avatar_url\n        self.href = href\n\n        if flags:\n            try:\n                self.flags = int(flags)\n                if self.flags < self.template_args[\"flags\"][\"min\"]:\n                    raise ValueError()\n\n            except (TypeError, ValueError):\n                msg = (\n                    f\"An invalid Fluxer flags setting ({flags}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n        else:\n            self.flags = None\n\n        self.ping: list[str] = parse_list(ping)\n\n        self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)\n        self.ratelimit_remaining = self.default_delay_sec\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        attach=None,\n        **kwargs: Any,\n    ) -> bool:\n\n        \"\"\"Perform Fluxer Notification.\"\"\"\n\n        # Prepare our headers:\n        payload = {\n            \"tts\": self.tts,\n            # If Text-To-Speech is set to True, then we do not want to wait\n            # for the whole message before continuing. Otherwise, we wait\n            \"wait\": self.tts is False,\n        }\n\n        # Acquire image_url\n        image_url = self.image_url(notify_type)\n\n        if self.avatar and (image_url or self.avatar_url):\n            payload[\"avatar_url\"] = (\n                self.avatar_url if self.avatar_url else image_url\n            )\n\n        if self.user:\n            # Optionally override the default username of the webhook\n            payload[\"username\"] = self.user\n\n        if self.thread_name:\n            payload[\"thread_name\"] = self.thread_name\n\n        params = {\"thread_id\": self.thread_id} if self.thread_id else None\n\n        if self.notify_format == NotifyFormat.MARKDOWN:\n            if self.ping:\n                payload.update(self.ping_payload(body, \" \".join(self.ping)))\n            else:\n                payload.update(self.ping_payload(body))\n\n        elif self.ping:\n            payload.update(self.ping_payload(\" \".join(self.ping)))\n\n        if body:\n            fields: list[dict[str, str]] = []\n\n            if self.notify_format == NotifyFormat.MARKDOWN:\n                embed: dict[str, Any] = {\n                    \"author\": {\n                        \"name\": self.app_id,\n                        \"url\": self.app_url,\n                    },\n                    \"description\": body,\n                    \"color\": self.color(notify_type, int),\n                }\n\n                # Fluxer strictly validates fields; omit 'title' if it's empty\n                if title:\n                    embed[\"title\"] = title\n\n                payload[\"embeds\"] = [embed]\n\n                if self.href:\n                    payload[\"embeds\"][0][\"url\"] = self.href\n\n                if self.footer:\n                    logo_url = self.image_url(notify_type, logo=True)\n                    payload[\"embeds\"][0][\"footer\"] = {\n                        \"text\": self.app_desc,\n                    }\n                    if self.footer_logo and logo_url:\n                        payload[\"embeds\"][0][\"footer\"][\"icon_url\"] = logo_url\n\n                if self.include_image and image_url:\n                    payload[\"embeds\"][0][\"thumbnail\"] = {\n                        \"url\": image_url,\n                        \"height\": 256,\n                        \"width\": 256,\n                    }\n\n                if self.fields:\n                    # Break titles out so that we can sort them in embeds\n                    description, fields = self.extract_markdown_sections(body)\n\n                    # Swap first entry for description\n                    payload[\"embeds\"][0][\"description\"] = description\n                    if fields:\n                        # Apply our additional parsing for a better\n                        # presentation\n                        payload[\"embeds\"][0][\"fields\"] = fields[\n                            : self.fluxer_max_fields\n                        ]\n                        fields = fields[self.fluxer_max_fields :]\n\n            else:\n                # TEXT or HTML:\n                # - No ping detection unless ping= was provided.\n                # - If ping= was provided, ping_payload() already generated\n                #   payload[\"content\"] starting with \"👉 ...\", and we append\n                #   it.\n                payload[\"content\"] = (\n                    body if not title else f\"{title}\\r\\n{body}\"\n                ) + payload.get(\"content\", \"\")\n\n            if not self._send(payload, params=params):\n                # We failed to post our message\n                return False\n\n            # Send remaining fields (if any)\n            if fields:\n                payload[\"embeds\"][0][\"description\"] = \"\"\n                for i in range(0, len(fields), self.fluxer_max_fields):\n                    payload[\"embeds\"][0][\"fields\"] = fields[\n                        i : i + self.fluxer_max_fields\n                    ]\n                    if not self._send(payload, params=params):\n                        return False\n\n        if attach and self.attachment_support:\n\n            # Update our payload; the idea is to preserve it's other detected\n            # and assigned values for re-use here too\n            payload.update({\n                # Text-To-Speech can be off so we don't read the filename\n                \"tts\": False,\n                # no tts; no need to wait\n                \"wait\": False,\n            })\n\n            #\n            # Remove our text/title based content for attachment use\n            #\n            payload.pop(\"embeds\", None)\n            payload.pop(\"allow_mentions\", None)\n\n            #\n            # Send our attachments\n            #\n            for attachment in attach:\n                self.logger.info(\n                    f\"Posting Fluxer Attachment {attachment.name}\"\n                )\n\n                if not self._send(payload, params=params, attach=attachment):\n                    # We failed to post our message\n                    return False\n\n        return True\n\n    def _send(\n        self,\n        payload: dict[str, Any],\n        params: dict[str, str] | None = None,\n        rate_limit: int = 1,\n        attach: AttachBase | None = None,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n\n        # Our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        if self.mode == FluxerMode.CLOUD:\n            prefix = self.cloud_notify_host\n\n        else:\n            # Prepare our Fluxer Template URL\n            schema = \"https\" if self.secure else \"http\"\n\n            # Construct Notify URL\n            prefix = f\"{schema}://{self.host}\"\n            if isinstance(self.port, int):\n                prefix += f\":{self.port}\"\n\n        notify_url = self.notify_url.format(\n            prefix=prefix,\n            webhook_id=self.webhook_id,\n            webhook_token=self.webhook_token,\n        )\n\n        safe_url = self.notify_url.format(\n            prefix=prefix,\n            webhook_id=self.pprint(self.webhook_id, True, safe=\"\"),\n            webhook_token=self.pprint(self.webhook_token, True, safe=\"\"),\n        )\n\n        self.logger.debug(\n            \"Fluxer POST URL: %s (cert_verify=%r)\",\n            safe_url,\n            self.verify_certificate,\n        )\n        self.logger.debug(\"Fluxer Payload: %s\", payload)\n\n        wait: float | None = None\n\n        if self.ratelimit_remaining <= 0.0:\n            now = datetime.now(timezone.utc).replace(tzinfo=None)\n\n            if now >= self.ratelimit_reset:\n                # Our block window has passed; clear it so we do not keep\n                # re-entering this logic on subsequent sends.\n                self.ratelimit_remaining = 1.0\n                wait = None\n\n            else:\n                wait = (self.ratelimit_reset - now).total_seconds()\n\n        self.throttle(wait=wait)\n\n        # Perform some simple error checking\n        if isinstance(attach, AttachBase):\n            if not attach:\n                # We could not access the attachment\n                self.logger.error(\n                    f\"Could not access attachment {attach.url(privacy=True)}.\"\n                )\n                return False\n\n            self.logger.debug(\n                f\"Posting Fluxer attachment {attach.url(privacy=True)}\"\n            )\n\n        # Our attachment path (if specified)\n        files = None\n        data: dict[str, Any] | str\n        try:\n\n            # Open our attachment path if required:\n            if attach:\n                #\n                # Fluxer requires content to be provided\n                #\n                payload.update({\n                    \"content\": attach.name,\n                    \"attachments\": [{\n                        \"id\": 0,\n                        \"filename\": attach.name,\n                    }],\n                })\n                files = {\n                    \"files[0]\": (\n                        attach.name,\n                        # file handle is safely closed in `finally`; inline\n                        # open is intentional\n                        open(attach.path, \"rb\"),  # noqa: SIM115\n                        # Explicitly declare the file type so the server\n                        # doesn't hang\n                        attach.mimetype,\n                    )\n                }\n                data = {\n                    \"payload_json\": dumps(payload),\n                }\n            else:\n                headers[\"Content-Type\"] = \"application/json; charset=utf-8\"\n                data = dumps(payload)\n\n            r = requests.post(\n                notify_url,\n                params=params,\n                data=data,\n                files=files,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code == requests.codes.too_many_requests:\n                # Determine a delay (seconds) using Retry-After\n                delay = self.default_delay_sec\n                try:\n                    ra = r.headers.get(\"Retry-After\")\n                    if ra is not None:\n                        delay = float(ra)\n\n                except (TypeError, ValueError):\n                    delay = self.default_delay_sec\n\n                # Enforce a minimum delay\n                delay = max(self.default_delay_sec, delay)\n\n                # Put ourselves into a blocked state until ratelimit_reset\n                now = datetime.now(timezone.utc).replace(tzinfo=None)\n                self.ratelimit_remaining = 0.0\n                self.ratelimit_reset = now + timedelta(seconds=delay)\n\n                self.logger.warning(\n                    \"Fluxer rate limiting in effect; blocking for %.2f \"\n                    \"second(s)\",\n                    delay,\n                )\n\n                if rate_limit > 0:\n                    # Prevent file handle leak before recursion\n                    if files:\n                        for file_info in files.values():\n                            with contextlib.suppress(Exception):\n                                file_info[1].close()\n                        files = None\n\n                    # Recursive retry; next _send() invocation will hit the\n                    # ratelimit_remaining<=0 gate and sleep via throttle()\n                    return self._send(\n                        payload=payload,\n                        params=params,\n                        rate_limit=rate_limit - 1,\n                        attach=attach,\n                        **kwargs,\n                    )\n\n                # No retries left\n                return False\n\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Fluxer notification: %s, error=%d.\",\n                    status_str,\n                    r.status_code,\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000]\n                )\n                return False\n\n            self.logger.info(\"Sent Fluxer notification.\")\n\n            # Reset Rate Limiting (a bit of a hacky approach for now)\n            # TODO: Learn more about how ratelimiting works with Fluxer\n            self.ratelimit_reset = \\\n                datetime.now(timezone.utc).replace(tzinfo=None)\n            self.ratelimit_remaining = self.default_delay_sec\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Fluxer notification.\"\n            )\n            self.logger.debug(\"Socket Exception: %s\", e)\n            return False\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while reading attachment(s)\")\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return False\n\n        finally:\n            # Close our file (if it's open) stored in the second element\n            # of our files tuple (index 1)\n            if files:\n                for file_info in files.values():\n                    with contextlib.suppress(Exception):\n                        file_info[1].close()\n\n        return True\n\n    def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        params: dict[str, str] = {\n            \"mode\": self.mode,\n            \"tts\": \"yes\" if self.tts else \"no\",\n            \"avatar\": \"yes\" if self.avatar else \"no\",\n            \"footer\": \"yes\" if self.footer else \"no\",\n            \"footer_logo\": \"yes\" if self.footer_logo else \"no\",\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"fields\": \"yes\" if self.fields else \"no\",\n        }\n\n        if self.avatar_url:\n            params[\"avatar_url\"] = self.avatar_url\n\n        if self.flags is not None:\n            params[\"flags\"] = str(self.flags)\n\n        if self.href:\n            params[\"href\"] = self.href\n\n        if self.thread_id:\n            params[\"thread\"] = self.thread_id\n\n        if self.thread_name:\n            params[\"thread_name\"] = self.thread_name\n\n        if self.ping:\n            params[\"ping\"] = \",\".join(self.ping)\n\n        botname = f\"{self.user}@\" if self.user else \"\"\n\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.mode == FluxerMode.PRIVATE:\n            default_port = 443 if self.secure else 80\n            port = (\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            )\n\n            schema = self.secure_protocol if self.secure else self.protocol\n            return (\n                \"{schema}://{bname}{host}{port}/{webhook_id}/{webhook_token}\"\n                \"/?{params}\".format(\n                    schema=schema,\n                    bname=botname,\n                    host=self.host,\n                    port=port,\n                    webhook_id=self.pprint(self.webhook_id, privacy, safe=\"\"),\n                    webhook_token=self.pprint(\n                        self.webhook_token, privacy, safe=\"\"\n                    ),\n                    params=NotifyFluxer.urlencode(params),\n                )\n            )\n\n        # Cloud Mode\n        return (\n            \"{schema}://{bname}{webhook_id}/{webhook_token}/?{params}\".format(\n                schema=self.protocol,\n                bname=botname,\n                webhook_id=self.pprint(self.webhook_id, privacy, safe=\"\"),\n                webhook_token=self.pprint(\n                    self.webhook_token, privacy, safe=\"\"\n                ),\n                params=NotifyFluxer.urlencode(params),\n            )\n        )\n\n    @property\n    def url_identifier(self) -> tuple[Any, ...]:\n        \"\"\"Returns all of the identifiers that make this URL unique.\"\"\"\n        kwargs = (\n            (\n                self.secure_protocol\n                if self.mode == FluxerMode.CLOUD\n                else (self.secure_protocol if self.secure else self.protocol)\n            ),\n            self.host if self.mode == FluxerMode.PRIVATE else \"\",\n            (\n                \"\" if self.mode == FluxerMode.CLOUD\n                else (self.port if self.port else (443 if self.secure else 80))\n            ),\n            self.webhook_id,\n            self.webhook_token,\n        )\n\n        return kwargs\n\n    @staticmethod\n    def parse_url(url: str) -> dict[str, Any] | None:\n        \"\"\"Parses the URL and returns arguments for instantiating this object.\n\n        Syntax:\n          fluxer://webhook_id/webhook_token\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Tokenize our URL\n        tokens = [\n            NotifyFluxer.unquote(results[\"host\"]),\n            *NotifyFluxer.split_path(results[\"fullpath\"]),\n        ]\n\n        # Text To Speech\n        results[\"tts\"] = parse_bool(results[\"qsd\"].get(\n            \"tts\", NotifyFluxer.template_args[\"tts\"][\"default\"]))\n\n        # Mode override\n        if \"mode\" in results[\"qsd\"] and results[\"qsd\"][\"mode\"]:\n            results[\"mode\"] = NotifyFluxer.unquote(\n                results[\"qsd\"][\"mode\"].strip().lower()\n            )\n\n        else:\n            # We can try to detect the mode based on the validity of the\n            # hostname.\n            #\n            # This isn't a surfire way to do things though; it's best to\n            # specify the mode= flag\n            results[\"mode\"] = (\n                FluxerMode.PRIVATE\n                if (\n                    (\n                        is_hostname(results[\"host\"])\n                        or is_ipaddr(results[\"host\"])\n                    )\n                    and len(tokens) > 2\n                )\n                else FluxerMode.CLOUD\n            )\n\n        results[\"footer\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"footer\", NotifyFluxer.template_args[\"footer\"][\"default\"]\n            )\n        )\n        results[\"footer_logo\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"footer_logo\",\n                NotifyFluxer.template_args[\"footer_logo\"][\"default\"]\n            )\n        )\n        results[\"fields\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"fields\", NotifyFluxer.template_args[\"fields\"][\"default\"]\n            )\n        )\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyFluxer.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        if \"botname\" in results[\"qsd\"]:\n            results[\"user\"] = NotifyFluxer.unquote(results[\"qsd\"][\"botname\"])\n\n        elif \"name\" in results[\"qsd\"]:\n            results[\"user\"] = NotifyFluxer.unquote(results[\"qsd\"][\"name\"])\n\n        if \"flags\" in results[\"qsd\"]:\n            results[\"flags\"] = NotifyFluxer.unquote(results[\"qsd\"][\"flags\"])\n\n        if \"avatar_url\" in results[\"qsd\"]:\n            results[\"avatar_url\"] = NotifyFluxer.unquote(\n                results[\"qsd\"][\"avatar_url\"]\n            )\n\n        if \"href\" in results[\"qsd\"]:\n            results[\"href\"] = NotifyFluxer.unquote(results[\"qsd\"][\"href\"])\n            results[\"format\"] = NotifyFormat.MARKDOWN\n\n        elif \"url\" in results[\"qsd\"]:\n            results[\"href\"] = NotifyFluxer.unquote(results[\"qsd\"][\"url\"])\n            results[\"format\"] = NotifyFormat.MARKDOWN\n\n        # Update Avatar Icon\n        results[\"avatar\"] = parse_bool(results[\"qsd\"].get(\n            \"avatar\", NotifyFluxer.template_args[\"avatar\"][\"default\"]))\n\n        if \"thread\" in results[\"qsd\"]:\n            results[\"thread\"] = NotifyFluxer.unquote(results[\"qsd\"][\"thread\"])\n            results[\"format\"] = NotifyFormat.MARKDOWN\n\n        if \"thread_name\" in results[\"qsd\"]:\n            results[\"thread_name\"] = NotifyFluxer.unquote(\n                results[\"qsd\"][\"thread_name\"]\n            )\n\n        if \"ping\" in results[\"qsd\"]:\n            results[\"ping\"] = NotifyFluxer.unquote(results[\"qsd\"][\"ping\"])\n\n        # Pop our tokens from back to front\n        results[\"webhook_token\"] = None if not tokens else tokens.pop()\n        results[\"webhook_id\"] = None if not tokens else tokens.pop()\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url: str) -> dict[str, Any] | None:\n        \"\"\"\n        Supported:\n          - https://api.fluxer.app/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n          - https://api.fluxer.app/v1/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://(api\\.)?fluxer\\.app/\"\n            r\"(?:(?:v[0-9]+/)?webhooks)/\"\n            r\"(?P<webhook_id>[0-9]+)/\"\n            r\"(?P<webhook_token>[A-Z0-9_-]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyFluxer.parse_url(\n                \"{schema}://{webhook_id}/{webhook_token}/{params}\".format(\n                    schema=NotifyFluxer.secure_protocol,\n                    webhook_id=result.group(\"webhook_id\"),\n                    webhook_token=result.group(\"webhook_token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n\n    def ping_payload(self, *args: str) -> dict[str, Any]:\n        \"\"\"Build allow_mentions + mention content.\"\"\"\n\n        payload: dict[str, Any] = {}\n\n        roles: set[str] = set()\n        users: set[str] = set()\n        parse: set[str] = set()\n\n        for arg in args:\n            # parse for user id's <@123> and role IDs <@&456>\n            results = USER_ROLE_DETECTION_RE.findall(arg)\n            if not results:\n                continue\n\n            for is_role, no, value in results:\n                if value:\n                    parse.add(value)\n\n                elif is_role:\n                    roles.add(no)\n\n                else:  # is_user\n                    users.add(no)\n\n        if not (roles or users or parse):\n            # Nothing to add\n            return payload\n\n        payload[\"allow_mentions\"] = {\n            \"parse\": list(parse),\n            \"users\": list(users),\n            \"roles\": list(roles),\n        }\n\n        payload[\"content\"] = \"👉 \" + \" \".join(\n            chain(\n                [f\"@{value}\" for value in parse],\n                [f\"<@&{value}>\" for value in roles],\n                [f\"<@{value}>\" for value in users],\n            )\n        )\n\n        return payload\n\n    @staticmethod\n    def extract_markdown_sections(\n            markdown: str) -> tuple[str, list[dict[str, str]]]:\n        \"\"\"Extract headers and their corresponding sections into embed\n        fields.\"\"\"\n\n        # Search for any header information found without it's own section\n        # identifier\n        match = re.match(\n            r\"^\\s*(?P<desc>[^\\s#]+.*?)(?=\\s*$|[\\r\\n]+\\s*#)\",\n            markdown,\n            flags=re.S,\n        )\n\n        description = match.group(\"desc\").strip() if match else \"\"\n        if description:\n            # Strip description from our string since it has been handled\n            # now.\n            markdown = re.sub(re.escape(description), \"\", markdown, count=1)\n\n        regex = re.compile(\n            r\"\\s*#[# \\t\\v]*(?P<name>[^\\n]+)(\\n|\\s*$)\"\n            r\"\\s*((?P<value>[^#].+?)(?=\\s*$|[\\r\\n]+\\s*#))?\",\n            flags=re.S,\n        )\n\n        common = regex.finditer(markdown)\n        fields: list[dict[str, str]] = []\n        for el in common:\n            d = el.groupdict()\n\n            fields.append({\n                \"name\": d.get(\"name\", \"\").strip(\"#`* \\r\\n\\t\\v\"),\n                \"value\": \"```{}\\n{}```\".format(\n                    \"md\" if d.get(\"value\") else \"\",\n                    (d.get(\"value\").strip() + \"\\n\" if d.get(\"value\") else \"\"),\n                ),\n            })\n\n        return description, fields\n"
  },
  {
    "path": "apprise/plugins/fortysixelks.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\"\"\"\n46elks SMS Notification Service.\n\nMinimal URL formats (source ends up being target):\n  - 46elks://user:pass@/+15551234567\n  - 46elks://user:pass@/+15551234567/+46701234567\n  - 46elks://user:pass@/+15551234567?from=Acme\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections.abc import Iterable\nimport re\nfrom typing import Any, Optional\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_phone_no,\n)\nfrom .base import NotifyBase\n\n\nclass Notify46Elks(NotifyBase):\n    \"\"\"A wrapper for 46elks Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"46elks\")\n\n    # The services URL\n    service_url = \"https://46elks.com\"\n\n    # The default secure protocol\n    secure_protocol = (\"46elks\", \"elks\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/46elks/\"\n\n    # 46elksAPI Request URLs\n    notify_url = \"https://api.46elks.com/a1/sms\"\n\n    # The maximum allowable characters allowed in the title per message\n    title_maxlen = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 160\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}:{password}@/{from_phone}\",\n        \"{schema}://{user}:{password}@/{from_phone}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"API Username\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"API Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"from\": {\n                \"alias_of\": \"from_phone\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        targets: Optional[Iterable[str]] = None,\n        source: Optional[str] = None,\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"\n        Initialise 46elks notifier.\n\n        :param targets: Iterable of phone numbers. E.164 is recommended.\n        :param source: Optional source ID or E.164 number.\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare our source\n        self.source: Optional[str] = (source or \"\").strip() or None\n\n        if not self.password:\n            msg = \"No 46elks password was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        elif not self.user:\n            msg = \"No 46elks user was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Parse our targets\n        self.targets = []\n\n        if not targets and is_phone_no(self.source):\n            targets = [self.source]\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            # Carry forward '+' if defined, otherwise do not...\n            self.targets.append(\n                (\"+\" + result[\"full\"])\n                if target.lstrip()[0] == \"+\"\n                else result[\"full\"]\n            )\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Perform 46elks Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\n                \"There are no 46elks recipients to notify\"\n            )\n            return False\n\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        targets = list(self.targets)\n        while targets:\n            target = targets.pop(0)\n\n            # Prepare our payload\n            payload = {\n                \"to\": target,\n                \"from\": self.source,\n                \"message\": body,\n            }\n\n            self.logger.debug(\n                \"46elks POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"46elks Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=payload,\n                    headers=headers,\n                    auth=(self.user, self.password),\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = (\n                        Notify46Elks.http_response_code_lookup(\n                            r.status_code\n                        )\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send 46elks notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent 46elks notification to {target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending 46elks\"\n                    f\" notification to {target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another similar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol[0], self.user, self.password, self.source)\n\n    def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Initialize our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        # Apprise URL can be condensed and target can be eliminated if its\n        # our source phone no\n        targets = (\n            [] if len(self.targets) == 1 and\n            self.source in self.targets else self.targets)\n\n        return \"{schema}://{user}:{pw}@{source}/{targets}?{params}\".format(\n            schema=self.secure_protocol[0],\n            user=self.quote(self.user, safe=\"\"),\n            source=self.source if self.source else \"\",\n            pw=self.pprint(\n                self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"),\n            targets=\"/\".join(\n                [Notify46Elks.quote(x, safe=\"+\") for x in targets]\n            ),\n            params=Notify46Elks.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://user:pw@api.46elks.com/a1/sms?to=+15551234567&from=Acme\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://(?P<credentials>[^@]+)@\"\n            r\"api\\.46elks\\.com/a1/sms/?\"\n            r\"(?P<params>\\?.+)$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return Notify46Elks.parse_url(\n                \"{schema}://{credentials}@/{params}\".format(\n                    schema=Notify46Elks.secure_protocol[0],\n                    credentials=result.group(\"credentials\"),\n                    params=result.group(\"params\"),\n                )\n            )\n\n        return None\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Prepare our targets\n        results[\"targets\"] = []\n\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = Notify46Elks.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n        elif results[\"host\"]:\n            results[\"source\"] = Notify46Elks.unquote(results[\"host\"])\n\n        # Store our remaining targets found on path\n        results[\"targets\"].extend(\n            Notify46Elks.split_path(results[\"fullpath\"])\n        )\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += Notify46Elks.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/freemobile.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Free Mobile\n#   1. Visit https://mobile.free.fr/\n\n# the URL will look something like this:\n#      https://smsapi.free-mobile.fr/sendmsg\n#\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom .base import NotifyBase\n\n\nclass NotifyFreeMobile(NotifyBase):\n    \"\"\"A wrapper for Free-Mobile Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Free-Mobile\")\n\n    # The services URL\n    service_url = \"https://mobile.free.fr/\"\n\n    # The default secure protocol\n    secure_protocol = \"freemobile\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/freemobile/\"\n\n    # Plain Text Notification URL\n    notify_url = \"https://smsapi.free-mobile.fr/sendmsg\"\n\n    # Define object templates\n    templates = (\"{schema}://{user}@{password}\",)\n\n    # The title is not used\n    title_maxlen = 0\n\n    # SMS Messages are restricted in size\n    body_maxlen = 160\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n        },\n    )\n\n    def __init__(self, **kwargs):\n        \"\"\"Initialize Free Mobile Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if not (self.user and self.password):\n            msg = (\n                \"A FreeMobile user and password combination was not provided.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user, self.password)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Prepare our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{user}@{password}/?{params}\".format(\n            schema=self.secure_protocol,\n            user=self.user,\n            password=self.pprint(self.password, privacy, safe=\"\"),\n            params=NotifyFreeMobile.urlencode(params),\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send our notification.\"\"\"\n\n        # prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {\"user\": self.user, \"pass\": self.password, \"msg\": body}\n\n        self.logger.debug(\n            \"Free Mobile GET URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Free Mobile Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=dumps(payload).encode(\"utf-8\"),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyFreeMobile.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Free Mobile notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Free Mobile notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Free Mobile notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        # parse_url already handles getting the `user` and `password` fields\n        # populated.\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The hostname can act as the password if specified and a password\n        # was otherwise not (specified):\n        if not results.get(\"password\"):\n            results[\"password\"] = NotifyFreeMobile.unquote(results[\"host\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/glib.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport sys\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n# Default our global support flag\nNOTIFY_GLIB_SUPPORT_ENABLED = False\n\n# Image support is dependant on the GdkPixbuf library being available\nNOTIFY_GLIB_IMAGE_SUPPORT = False\n\n\ntry:\n    # glib essentials\n    import gi\n    gi.require_version(\"Gio\", \"2.0\")\n    gi.require_version(\"GLib\", \"2.0\")\n    from gi.repository import Gio, GLib\n\n    # We're good\n    NOTIFY_GLIB_SUPPORT_ENABLED = True\n\n    # ImportError: When using gi.repository you must not import static modules\n    # like \"gobject\". Please change all occurrences of \"import gobject\" to\n    # \"from gi.repository import GObject\".\n    # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183\n    if \"gobject\" in sys.modules:  # pragma: no cover\n        del sys.modules[\"gobject\"]\n\n    try:\n        # The following is required for Image/Icon loading only\n        gi.require_version(\"GdkPixbuf\", \"2.0\")\n        from gi.repository import GdkPixbuf\n        NOTIFY_GLIB_IMAGE_SUPPORT = True\n\n    except (ImportError, ValueError, AttributeError):\n        # No problem; this will get caught in outer try/catch\n\n        # A ValueError will get thrown upon calling gi.require_version() if\n        # GDK/GTK isn't installed on the system but gi is.\n        pass\n\nexcept ImportError:\n    # No problem; we just simply can't support this plugin; we could\n    # be in microsoft windows, or we just don't have the python-gobject\n    # library available to us (or maybe one we don't support)?\n    pass\n\n\n# Urgencies\nclass GLibUrgency:\n    LOW = 0\n    NORMAL = 1\n    HIGH = 2\n\n\nGLIB_URGENCIES = {\n    # Note: This also acts as a reverse lookup mapping\n    GLibUrgency.LOW: \"low\",\n    GLibUrgency.NORMAL: \"normal\",\n    GLibUrgency.HIGH: \"high\",\n}\n\nGLIB_URGENCY_MAP = {\n    # Maps against string 'low'\n    \"l\": GLibUrgency.LOW,\n    # Maps against string 'moderate'\n    \"m\": GLibUrgency.LOW,\n    # Maps against string 'normal'\n    \"n\": GLibUrgency.NORMAL,\n    # Maps against string 'high'\n    \"h\": GLibUrgency.HIGH,\n    # Maps against string 'emergency'\n    \"e\": GLibUrgency.HIGH,\n\n    # Entries to additionally support (so more like DBus's API)\n    \"0\": GLibUrgency.LOW,\n    \"1\": GLibUrgency.NORMAL,\n    \"2\": GLibUrgency.HIGH,\n}\n\n\nclass NotifyGLib(NotifyBase):\n    \"\"\"\n    A wrapper for local GLib/Gio Notifications\n    \"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_GLIB_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"details\": _(\"libdbus-1.so.x or libdbus-2.so.x must be installed.\")\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"DBus Notification\")\n\n    # The services URL\n    service_url = \\\n        \"https://lazka.github.io/pgi-docs/Gio-2.0/classes/DBusProxy.html\"\n\n    # The default protocols\n    protocol = (\"glib\", \"gio\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/dbus/\"\n\n    # No throttling required for DBus queries\n    request_rate_per_sec = 0\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # The number of milliseconds to keep the message present for\n    message_timeout_ms = 13000\n\n    # Limit results to just the first 10 line otherwise there is just to much\n    # content to display\n    body_max_line_count = 10\n\n    # The following are required to hook into the notifications:\n    glib_interface = \"org.freedesktop.Notifications\"\n    glib_setting_location = \"/org/freedesktop/Notifications\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://\",\n    )\n\n    # Define our template arguments\n    template_args = dict(NotifyBase.template_args, **{\n        \"urgency\": {\n            \"name\": _(\"Urgency\"),\n            \"type\": \"choice:int\",\n            \"values\": GLIB_URGENCIES,\n            \"default\": GLibUrgency.NORMAL,\n        },\n        \"priority\": {\n            # Apprise uses 'priority' everywhere; it's just a nice consistent\n            # feel to be able to use it here as well. Just map the\n            # value back to 'priority'\n            \"alias_of\": \"urgency\",\n        },\n        \"x\": {\n            \"name\": _(\"X-Axis\"),\n            \"type\": \"int\",\n            \"min\": 0,\n            \"map_to\": \"x_axis\",\n        },\n        \"y\": {\n            \"name\": _(\"Y-Axis\"),\n            \"type\": \"int\",\n            \"min\": 0,\n            \"map_to\": \"y_axis\",\n        },\n        \"image\": {\n            \"name\": _(\"Include Image\"),\n            \"type\": \"bool\",\n            \"default\": True,\n            \"map_to\": \"include_image\",\n        },\n    })\n\n    def __init__(self, urgency=None, x_axis=None, y_axis=None,\n                 include_image=True, **kwargs):\n        \"\"\"\n        Initialize DBus Object\n        \"\"\"\n\n        super().__init__(**kwargs)\n\n        # Track our notifications\n        self.registry = {}\n\n        # The urgency of the message\n        self.urgency = int(\n            NotifyGLib.template_args[\"urgency\"][\"default\"]\n            if urgency is None else\n            next((\n                v for k, v in GLIB_URGENCY_MAP.items()\n                if str(urgency).lower().startswith(k)),\n                NotifyGLib.template_args[\"urgency\"][\"default\"]))\n\n        # Our x/y axis settings\n        if x_axis or y_axis:\n            try:\n                self.x_axis = int(x_axis)\n                self.y_axis = int(y_axis)\n\n            except (TypeError, ValueError):\n                # Invalid x/y values specified\n                msg = \"The x,y coordinates specified ({},{}) are invalid.\"\\\n                    .format(x_axis, y_axis)\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n        else:\n            self.x_axis = None\n            self.y_axis = None\n\n        # Track whether we want to add an image to the notification.\n        self.include_image = include_image\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"\n        Perform GLib/Gio Notification\n        \"\"\"\n        # Acquire our gio interface\n        try:\n            gio_iface = Gio.DBusProxy.new_for_bus_sync(\n                Gio.BusType.SESSION,\n                Gio.DBusProxyFlags.NONE,\n                None,\n                self.glib_interface,\n                self.glib_setting_location,\n                self.glib_interface,\n                None,\n            )\n\n        except GLib.Error as e:\n            # Handle exception\n            self.logger.warning(\"Failed to send GLib/Gio notification.\")\n            self.logger.debug(f\"GLib/Gio Exception: {e}\")\n            return False\n\n        # If there is no title, but there is a body, swap the two to get rid\n        # of the weird whitespace\n        if not title:\n            title = body\n            body = \"\"\n\n        # image path\n        icon_path = None if not self.include_image \\\n            else self.image_path(notify_type, extension=\".ico\")\n\n        # Our meta payload\n        meta_payload = {\n            \"urgency\": GLib.Variant(\"y\", self.urgency),\n        }\n\n        if not (self.x_axis is None and self.y_axis is None):\n            # Set x/y access if these were set\n            meta_payload[\"x\"] = GLib.Variant(\"i\", self.x_axis)\n            meta_payload[\"y\"] = GLib.Variant(\"i\", self.y_axis)\n\n        if NOTIFY_GLIB_IMAGE_SUPPORT and icon_path:\n            try:\n                # Use Pixbuf to create the proper image type\n                image = GdkPixbuf.Pixbuf.new_from_file(icon_path)\n\n                # Associate our image to our notification\n                meta_payload[\"icon_data\"] = GLib.Variant(\n                    \"(iiibiiay)\",\n                    (\n                        image.get_width(),\n                        image.get_height(),\n                        image.get_rowstride(),\n                        image.get_has_alpha(),\n                        image.get_bits_per_sample(),\n                        image.get_n_channels(),\n                        image.get_pixels(),\n                    ),\n                )\n\n            except Exception as e:\n                self.logger.warning(\n                    \"Could not load notification icon (%s).\", icon_path)\n                self.logger.debug(f\"GLib/Gio Exception: {e}\")\n\n        try:\n            # Always call throttle() before any remote execution is made\n            self.throttle()\n\n            gio_iface.Notify(\n                \"(susssasa{sv}i)\",\n                # Application Identifier\n                self.app_id,\n                # Message ID (0 = New Message)\n                0,\n                # Icon (str) - not used\n                \"\",\n                # Title\n                str(title),\n                # Body\n                str(body),\n                # Actions\n                [],\n                # Meta\n                meta_payload,\n                # Message Timeout\n                self.message_timeout_ms,\n            )\n\n            self.logger.info(\"Sent GLib/Gio notification.\")\n\n        except Exception as e:\n            self.logger.warning(\"Failed to send GLib/Gio notification.\")\n            self.logger.debug(f\"GLib/Gio Exception: {e}\")\n            return False\n\n        return True\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"\n        Returns the URL built dynamically based on specified arguments.\n        \"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"urgency\":\n                GLIB_URGENCIES[self.template_args[\"urgency\"][\"default\"]]\n                if self.urgency not in GLIB_URGENCIES\n                else GLIB_URGENCIES[self.urgency],\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # x in (x,y) screen coordinates\n        if self.x_axis:\n            params[\"x\"] = str(self.x_axis)\n\n        # y in (x,y) screen coordinates\n        if self.y_axis:\n            params[\"y\"] = str(self.y_axis)\n\n        schema = self.protocol[0]\n        return f\"{schema}://_/?{NotifyGLib.urlencode(params)}\"\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"\n        There are no parameters necessary for this protocol; simply having\n        gnome:// is all you need.  This function just makes sure that\n        is in place.\n\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        # Include images with our message\n        results[\"include_image\"] = \\\n            parse_bool(results[\"qsd\"].get(\"image\", True))\n\n        # GLib/Gio supports urgency, but we we also support the keyword\n        # priority so that it is consistent with some of the other plugins\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            # We intentionally store the priority in the urgency section\n            results[\"urgency\"] = \\\n                NotifyGLib.unquote(results[\"qsd\"][\"priority\"])\n\n        if \"urgency\" in results[\"qsd\"] and len(results[\"qsd\"][\"urgency\"]):\n            results[\"urgency\"] = \\\n                NotifyGLib.unquote(results[\"qsd\"][\"urgency\"])\n\n        # handle x,y coordinates\n        if \"x\" in results[\"qsd\"] and len(results[\"qsd\"][\"x\"]):\n            results[\"x_axis\"] = NotifyGLib.unquote(results[\"qsd\"].get(\"x\"))\n\n        if \"y\" in results[\"qsd\"] and len(results[\"qsd\"][\"y\"]):\n            results[\"y_axis\"] = NotifyGLib.unquote(results[\"qsd\"].get(\"y\"))\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/gnome.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n# Default our global support flag\nNOTIFY_GNOME_SUPPORT_ENABLED = False\n\ntry:\n    # 3rd party modules (Gnome Only)\n    import gi\n\n    # require_version() call is required otherwise we generate a warning\n    gi.require_version(\"Notify\", \"0.7\")\n    gi.require_version(\"GdkPixbuf\", \"2.0\")\n\n    # We can import the actual libraries we care about now:\n    from gi.repository import GdkPixbuf, Notify\n\n    # We're good to go!\n    NOTIFY_GNOME_SUPPORT_ENABLED = True\n\nexcept (ImportError, ValueError, AttributeError):\n    # No problem; we just simply can't support this plugin; we could\n    # be in microsoft windows, or we just don't have the python-gobject\n    # library available to us (or maybe one we don't support)?\n\n    # Alternatively, a `ValueError` will get raised upon calling\n    # gi.require_version() if the requested Notify namespace isn't available.\n    pass\n\n\n# Urgencies\nclass GnomeUrgency:\n    LOW = 0\n    NORMAL = 1\n    HIGH = 2\n\n\nGNOME_URGENCIES = {\n    GnomeUrgency.LOW: \"low\",\n    GnomeUrgency.NORMAL: \"normal\",\n    GnomeUrgency.HIGH: \"high\",\n}\n\n\nGNOME_URGENCY_MAP = {\n    # Maps against string 'low'\n    \"l\": GnomeUrgency.LOW,\n    # Maps against string 'moderate'\n    \"m\": GnomeUrgency.LOW,\n    # Maps against string 'normal'\n    \"n\": GnomeUrgency.NORMAL,\n    # Maps against string 'high'\n    \"h\": GnomeUrgency.HIGH,\n    # Maps against string 'emergency'\n    \"e\": GnomeUrgency.HIGH,\n    # Entries to additionally support (so more like Gnome's API)\n    \"0\": GnomeUrgency.LOW,\n    \"1\": GnomeUrgency.NORMAL,\n    \"2\": GnomeUrgency.HIGH,\n}\n\n\nclass NotifyGnome(NotifyBase):\n    \"\"\"A wrapper for local Gnome Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_GNOME_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"details\": _(\"A local Gnome environment is required.\")\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Gnome Notification\")\n\n    # The service URL\n    service_url = \"https://www.gnome.org/\"\n\n    # The default protocol\n    protocol = \"gnome\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/gnome/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # Disable throttle rate for Gnome requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Limit results to just the first 10 line otherwise there is just to much\n    # content to display\n    body_max_line_count = 10\n\n    # A title can not be used for Gnome Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # No URL Identifier will be defined for this service as there simply isn't\n    # enough details to uniquely identify one dbus:// from another.\n    url_identifier = False\n\n    # Define object templates\n    templates = (\"{schema}://\",)\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"urgency\": {\n                \"name\": _(\"Urgency\"),\n                \"type\": \"choice:int\",\n                \"values\": GNOME_URGENCIES,\n                \"default\": GnomeUrgency.NORMAL,\n            },\n            \"priority\": {\n                # Apprise uses 'priority' everywhere; it's just a nice\n                # consistent feel to be able to use it here as well. Just map\n                # the value back to 'priority'\n                \"alias_of\": \"urgency\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    def __init__(self, urgency=None, include_image=True, **kwargs):\n        \"\"\"Initialize Gnome Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        # The urgency of the message\n        self.urgency = int(\n            NotifyGnome.template_args[\"urgency\"][\"default\"]\n            if urgency is None\n            else next(\n                (\n                    v\n                    for k, v in GNOME_URGENCY_MAP.items()\n                    if str(urgency).lower().startswith(k)\n                ),\n                NotifyGnome.template_args[\"urgency\"][\"default\"],\n            )\n        )\n\n        # Track whether we want to add an image to the notification.\n        self.include_image = include_image\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Gnome Notification.\"\"\"\n\n        try:\n            # App initialization\n            Notify.init(self.app_id)\n\n            # image path\n            icon_path = (\n                None\n                if not self.include_image\n                else self.image_path(notify_type, extension=\".ico\")\n            )\n\n            # Build message body\n            notification = Notify.Notification.new(body)\n\n            # Assign urgency\n            notification.set_urgency(self.urgency)\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            if icon_path:\n                try:\n                    # Use Pixbuf to create the proper image type\n                    image = GdkPixbuf.Pixbuf.new_from_file(icon_path)\n\n                    # Associate our image to our notification\n                    notification.set_icon_from_pixbuf(image)\n                    notification.set_image_from_pixbuf(image)\n\n                except Exception as e:\n                    self.logger.warning(\n                        \"Could not load notification icon (%s).\", icon_path\n                    )\n                    self.logger.debug(f\"Gnome Exception: {e}\")\n\n            notification.show()\n            self.logger.info(\"Sent Gnome notification.\")\n\n        except Exception as e:\n            self.logger.warning(\"Failed to send Gnome notification.\")\n            self.logger.debug(f\"Gnome Exception: {e}\")\n            return False\n\n        return True\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"urgency\": (\n                GNOME_URGENCIES[self.template_args[\"urgency\"][\"default\"]]\n                if self.urgency not in GNOME_URGENCIES\n                else GNOME_URGENCIES[self.urgency]\n            ),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return f\"{self.protocol}://?{NotifyGnome.urlencode(params)}\"\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"There are no parameters nessisary for this protocol; simply having\n        gnome:// is all you need.\n\n        This function just makes sure that is in place.\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # Gnome supports urgency, but we we also support the keyword priority\n        # so that it is consistent with some of the other plugins\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            # We intentionally store the priority in the urgency section\n            results[\"urgency\"] = NotifyGnome.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        if \"urgency\" in results[\"qsd\"] and len(results[\"qsd\"][\"urgency\"]):\n            results[\"urgency\"] = NotifyGnome.unquote(results[\"qsd\"][\"urgency\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/google_chat.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this to work correctly you need to create a webhook. You'll also\n# need a GSuite account (there are free trials if you don't have one)\n#\n#  - Open Google Chat in your browser:\n#     Link: https://chat.google.com/\n#  - Go to the room to which you want to add a bot.\n#  - From the room menu at the top of the page, select Manage webhooks.\n#  - Provide it a name and optional avatar and click SAVE\n#  - Copy the URL listed next to your new webhook in the Webhook URL column.\n#  - Click outside the dialog box to close.\n#\n# When you've completed, you'll get a URL that looks a little like this:\n#  https://chat.googleapis.com/v1/spaces/AAAAk6lGXyM/\\\n#       messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&\\\n#       token=O7b1nyri_waOpLMSzbFILAGRzgtQofPW71fEEXKcyFk%3D\n#\n# Simplified, it looks like this:\n#     https://chat.googleapis.com/v1/spaces/WORKSPACE/messages?\\\n#       key=WEBHOOK_KEY&token=WEBHOOK_TOKEN\n#\n# This plugin will simply work using the url of:\n#     gchat://WORKSPACE/WEBHOOK_KEY/WEBHOOK_TOKEN\n#\n# API Documentation on Webhooks:\n#    - https://developers.google.com/hangouts/chat/quickstart/\\\n#         incoming-bot-python\n#    - https://developers.google.com/hangouts/chat/reference/rest\n#\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyGoogleChat(NotifyBase):\n    \"\"\"A wrapper to Google Chat Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Google Chat\"\n\n    # The services URL\n    service_url = \"https://chat.google.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"gchat\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/googlechat/\"\n\n    # Google Chat Webhook\n    notify_url = \"https://chat.googleapis.com/v1/spaces/{workspace}/messages\"\n\n    # Default Notify Format\n    notify_format = NotifyFormat.MARKDOWN\n\n    # A title can not be used for Google Chat Messages.  Setting this to zero\n    # will cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 4000\n\n    # Define object templates\n    templates = (\n        \"{schema}://{workspace}/{webhook_key}/{webhook_token}\",\n        \"{schema}://{workspace}/{webhook_key}/{webhook_token}/{thread_key}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"workspace\": {\n                \"name\": _(\"Workspace\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"webhook_key\": {\n                \"name\": _(\"Webhook Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"webhook_token\": {\n                \"name\": _(\"Webhook Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"thread_key\": {\n                \"name\": _(\"Thread Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"workspace\": {\n                \"alias_of\": \"workspace\",\n            },\n            \"key\": {\n                \"alias_of\": \"webhook_key\",\n            },\n            \"token\": {\n                \"alias_of\": \"webhook_token\",\n            },\n            \"thread\": {\n                \"alias_of\": \"thread_key\",\n            },\n        },\n    )\n\n    def __init__(\n        self, workspace, webhook_key, webhook_token, thread_key=None, **kwargs\n    ):\n        \"\"\"Initialize Google Chat Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Workspace (associated with project)\n        self.workspace = validate_regex(workspace)\n        if not self.workspace:\n            msg = (\n                \"An invalid Google Chat Workspace \"\n                f\"({workspace}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Webhook Key (associated with project)\n        self.webhook_key = validate_regex(webhook_key)\n        if not self.webhook_key:\n            msg = (\n                \"An invalid Google Chat Webhook Key \"\n                f\"({webhook_key}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Webhook Token (associated with project)\n        self.webhook_token = validate_regex(webhook_token)\n        if not self.webhook_token:\n            msg = (\n                \"An invalid Google Chat Webhook Token \"\n                f\"({webhook_token}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if thread_key:\n            self.thread_key = validate_regex(thread_key)\n            if not self.thread_key:\n                msg = (\n                    \"An invalid Google Chat Thread Key \"\n                    f\"({thread_key}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.thread_key = None\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Google Chat Notification.\"\"\"\n\n        # Our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json; charset=utf-8\",\n        }\n\n        payload = {\n            # Our Message\n            \"text\": body,\n        }\n\n        # Construct Notify URL\n        notify_url = self.notify_url.format(\n            workspace=self.workspace,\n        )\n\n        params = {\n            # Prepare our URL Parameters\n            \"token\": self.webhook_token,\n            \"key\": self.webhook_key,\n        }\n\n        if self.thread_key:\n            params.update({\n                \"messageReplyOption\": \"REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD\",\n            })\n\n            payload.update({\n                \"thread\": {\n                    \"thread_key\": self.thread_key,\n                }\n            })\n\n        self.logger.debug(\n            \"Google Chat POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Google Chat Parameters: {params!s}\")\n        self.logger.debug(f\"Google Chat Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                notify_url,\n                params=params,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n\n                # We had a problem\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Google Chat notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Google Chat notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred postingto Google Chat.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.workspace,\n            self.webhook_key,\n            self.webhook_token,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Set our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{workspace}/{key}/{token}/{thread}?{params}\".format(\n            schema=self.secure_protocol,\n            workspace=self.pprint(self.workspace, privacy, safe=\"\"),\n            key=self.pprint(self.webhook_key, privacy, safe=\"\"),\n            token=self.pprint(self.webhook_token, privacy, safe=\"\"),\n            thread=(\n                \"\"\n                if not self.thread_key\n                else self.pprint(self.thread_key, privacy, safe=\"\")\n            ),\n            params=NotifyGoogleChat.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\n\n        Syntax:\n          gchat://workspace/webhook_key/webhook_token\n          gchat://workspace/webhook_key/webhook_token/thread_key\n        \"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Store our Workspace\n        results[\"workspace\"] = NotifyGoogleChat.unquote(results[\"host\"])\n\n        # Acquire our tokens\n        tokens = NotifyGoogleChat.split_path(results[\"fullpath\"])\n\n        # Store our Webhook Key\n        results[\"webhook_key\"] = tokens.pop(0) if tokens else None\n\n        # Store our Webhook Token\n        results[\"webhook_token\"] = tokens.pop(0) if tokens else None\n\n        # Store our Thread Key\n        results[\"thread_key\"] = tokens.pop(0) if tokens else None\n\n        # Support arguments as overrides (if specified)\n        if \"workspace\" in results[\"qsd\"]:\n            results[\"workspace\"] = NotifyGoogleChat.unquote(\n                results[\"qsd\"][\"workspace\"]\n            )\n\n        if \"key\" in results[\"qsd\"]:\n            results[\"webhook_key\"] = NotifyGoogleChat.unquote(\n                results[\"qsd\"][\"key\"]\n            )\n\n        if \"token\" in results[\"qsd\"]:\n            results[\"webhook_token\"] = NotifyGoogleChat.unquote(\n                results[\"qsd\"][\"token\"]\n            )\n\n        if \"thread\" in results[\"qsd\"]:\n            results[\"thread_key\"] = NotifyGoogleChat.unquote(\n                results[\"qsd\"][\"thread\"]\n            )\n\n        elif \"threadkey\" in results[\"qsd\"]:\n            # Support Google Chat's Thread Key (if set)\n            # keys are always made lowercase; so check above is attually\n            # testing threadKey successfully as well\n            results[\"thread_key\"] = NotifyGoogleChat.unquote(\n                results[\"qsd\"][\"threadkey\"]\n            )\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support\n           https://chat.googleapis.com/v1/spaces/{workspace}/messages\n                 '?key={key}&token={token}\n           https://chat.googleapis.com/v1/spaces/{workspace}/messages\n                 '?key={key}&token={token}&threadKey={thread}\n        \"\"\"\n\n        result = re.match(\n            r\"^https://chat\\.googleapis\\.com/v1/spaces/\"\n            r\"(?P<workspace>[A-Z0-9_-]+)/messages/*(?P<params>.+)$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyGoogleChat.parse_url(\n                \"{schema}://{workspace}/{params}\".format(\n                    schema=NotifyGoogleChat.secure_protocol,\n                    workspace=result.group(\"workspace\"),\n                    params=result.group(\"params\"),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/gotify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Gotify Docker configuration: https://hub.docker.com/r/gotify/server\n# Example: https://github.com/gotify/server/blob/\\\n#      f2c2688f0b5e6a816bbcec768ca1c0de5af76b88/ADD_MESSAGE_EXAMPLES.md#python\n# API: https://gotify.net/docs/swagger-docs\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\n# Priorities\nclass GotifyPriority:\n    LOW = 0\n    MODERATE = 3\n    NORMAL = 5\n    HIGH = 8\n    EMERGENCY = 10\n\n\nGOTIFY_PRIORITIES = {\n    # Note: This also acts as a reverse lookup mapping\n    GotifyPriority.LOW: \"low\",\n    GotifyPriority.MODERATE: \"moderate\",\n    GotifyPriority.NORMAL: \"normal\",\n    GotifyPriority.HIGH: \"high\",\n    GotifyPriority.EMERGENCY: \"emergency\",\n}\n\nGOTIFY_PRIORITY_MAP = {\n    # Maps against string 'low'\n    \"l\": GotifyPriority.LOW,\n    # Maps against string 'moderate'\n    \"m\": GotifyPriority.MODERATE,\n    # Maps against string 'normal'\n    \"n\": GotifyPriority.NORMAL,\n    # Maps against string 'high'\n    \"h\": GotifyPriority.HIGH,\n    # Maps against string 'emergency'\n    \"e\": GotifyPriority.EMERGENCY,\n    # Entries to additionally support (so more like Gotify's API)\n    \"10\": GotifyPriority.EMERGENCY,\n    # ^ 10 needs to be checked before '1' below or it will match the wrong\n    # priority\n    \"0\": GotifyPriority.LOW,\n    \"1\": GotifyPriority.LOW,\n    \"2\": GotifyPriority.LOW,\n    \"3\": GotifyPriority.MODERATE,\n    \"4\": GotifyPriority.MODERATE,\n    \"5\": GotifyPriority.NORMAL,\n    \"6\": GotifyPriority.NORMAL,\n    \"7\": GotifyPriority.NORMAL,\n    \"8\": GotifyPriority.HIGH,\n    \"9\": GotifyPriority.HIGH,\n}\n\n\nclass NotifyGotify(NotifyBase):\n    \"\"\"A wrapper for Gotify Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Gotify\"\n\n    # The services URL\n    service_url = \"https://github.com/gotify/server\"\n\n    # The default protocol\n    protocol = \"gotify\"\n\n    # The default secure protocol\n    secure_protocol = \"gotifys\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/gotify/\"\n\n    # Disable throttle rate\n    request_rate_per_sec = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{token}\",\n        \"{schema}://{host}:{port}/{token}\",\n        \"{schema}://{host}{path}{token}\",\n        \"{schema}://{host}:{port}{path}{token}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"path\": {\n                \"name\": _(\"Path\"),\n                \"type\": \"string\",\n                \"map_to\": \"fullpath\",\n                \"default\": \"/\",\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": GOTIFY_PRIORITIES,\n                \"default\": GotifyPriority.NORMAL,\n            },\n        },\n    )\n\n    def __init__(self, token, priority=None, **kwargs):\n        \"\"\"Initialize Gotify Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Token (associated with project)\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = f\"An invalid Gotify Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # prepare our fullpath\n        self.fullpath = kwargs.get(\"fullpath\", \"/\")\n\n        # The Priority of the message\n        self.priority = int(\n            NotifyGotify.template_args[\"priority\"][\"default\"]\n            if priority is None\n            else next(\n                (\n                    v\n                    for k, v in GOTIFY_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyGotify.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        if self.secure:\n            self.schema = \"https\"\n\n        else:\n            self.schema = \"http\"\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Gotify Notification.\"\"\"\n\n        url = f\"{self.schema}://{self.host}\"\n        if self.port:\n            url += f\":{self.port}\"\n\n        # Append our remaining path\n        url += f\"{self.fullpath}message\"\n\n        # Prepare Gotify Object\n        payload = {\n            \"priority\": self.priority,\n            \"title\": title,\n            \"message\": body,\n        }\n\n        if self.notify_format == NotifyFormat.MARKDOWN:\n            payload[\"extras\"] = {\n                \"client::display\": {\"contentType\": \"text/markdown\"}\n            }\n\n        # Our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"X-Gotify-Key\": self.token,\n        }\n\n        self.logger.debug(\n            f\"Gotify POST URL: {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Gotify Payload: {payload!s}\")\n\n        # Always call throttle before the requests are made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyGotify.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Gotify notification: {}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                return False\n\n            else:\n                self.logger.info(\"Sent Gotify notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Gotify \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n            self.fullpath.rstrip(\"/\"),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"priority\": (\n                GOTIFY_PRIORITIES[self.template_args[\"priority\"][\"default\"]]\n                if self.priority not in GOTIFY_PRIORITIES\n                else GOTIFY_PRIORITIES[self.priority]\n            ),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Our default port\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{hostname}{port}{fullpath}{token}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=NotifyGotify.quote(self.fullpath, safe=\"/\"),\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            params=NotifyGotify.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early\n            return results\n\n        # Retrieve our escaped entries found on the fullpath\n        entries = NotifyBase.split_path(results[\"fullpath\"])\n\n        # optionally find the provider key\n        try:\n            # The last entry is our token\n            results[\"token\"] = entries.pop()\n\n        except IndexError:\n            # No token was set\n            results[\"token\"] = None\n\n        # Re-assemble our full path\n        results[\"fullpath\"] = (\n            \"/\" if not entries else \"/{}/\".format(\"/\".join(entries))\n        )\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyGotify.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/growl.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n# Default our global support flag\nNOTIFY_GROWL_SUPPORT_ENABLED = False\n\ntry:\n    import gntp.notifier\n\n    # We're good to go!\n    NOTIFY_GROWL_SUPPORT_ENABLED = True\n\nexcept ImportError:\n    # No problem; we just simply can't support this plugin until\n    # gntp is installed\n    pass\n\n\n# Priorities\nclass GrowlPriority:\n    LOW = -2\n    MODERATE = -1\n    NORMAL = 0\n    HIGH = 1\n    EMERGENCY = 2\n\n\nGROWL_PRIORITIES = {\n    # Note: This also acts as a reverse lookup mapping\n    GrowlPriority.LOW: \"low\",\n    GrowlPriority.MODERATE: \"moderate\",\n    GrowlPriority.NORMAL: \"normal\",\n    GrowlPriority.HIGH: \"high\",\n    GrowlPriority.EMERGENCY: \"emergency\",\n}\n\nGROWL_PRIORITY_MAP = {\n    # Maps against string 'low'\n    \"l\": GrowlPriority.LOW,\n    # Maps against string 'moderate'\n    \"m\": GrowlPriority.MODERATE,\n    # Maps against string 'normal'\n    \"n\": GrowlPriority.NORMAL,\n    # Maps against string 'high'\n    \"h\": GrowlPriority.HIGH,\n    # Maps against string 'emergency'\n    \"e\": GrowlPriority.EMERGENCY,\n    # Entries to additionally support (so more like Growl's API)\n    \"-2\": GrowlPriority.LOW,\n    \"-1\": GrowlPriority.MODERATE,\n    \"0\": GrowlPriority.NORMAL,\n    \"1\": GrowlPriority.HIGH,\n    \"2\": GrowlPriority.EMERGENCY,\n}\n\n\nclass NotifyGrowl(NotifyBase):\n    \"\"\"A wrapper to Growl Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_GROWL_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"packages_required\": \"gntp\"\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Growl\"\n\n    # The services URL\n    service_url = \"http://growl.info/\"\n\n    # The default protocol\n    protocol = \"growl\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/growl/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # Disable throttle rate for Growl requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Limit results to just the first 10 line otherwise there is just to much\n    # content to display\n    body_max_line_count = 2\n\n    # Default Growl Port\n    default_port = 23053\n\n    # The Growl notification type used\n    growl_notification_type = \"New Messages\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{password}@{host}\",\n        \"{schema}://{password}@{host}:{port}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": GROWL_PRIORITIES,\n                \"default\": GrowlPriority.NORMAL,\n            },\n            \"version\": {\n                \"name\": _(\"Version\"),\n                \"type\": \"choice:int\",\n                \"values\": (1, 2),\n                \"default\": 2,\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"sticky\": {\n                \"name\": _(\"Sticky\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"sticky\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        priority=None,\n        version=2,\n        include_image=True,\n        sticky=False,\n        **kwargs,\n    ):\n        \"\"\"Initialize Growl Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if not self.port:\n            self.port = self.default_port\n\n        # The Priority of the message\n        self.priority = (\n            NotifyGrowl.template_args[\"priority\"][\"default\"]\n            if not priority\n            else next(\n                (\n                    v\n                    for k, v in GROWL_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyGrowl.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        # Our Registered object\n        self.growl = None\n\n        # Sticky flag\n        self.sticky = sticky\n\n        # Store Version\n        self.version = version\n\n        # Track whether or not we want to send an image with our notification\n        # or not.\n        self.include_image = include_image\n\n        return\n\n    def register(self):\n        \"\"\"Registers with the Growl server.\"\"\"\n        payload = {\n            \"applicationName\": self.app_id,\n            \"notifications\": [\n                self.growl_notification_type,\n            ],\n            \"defaultNotifications\": [\n                self.growl_notification_type,\n            ],\n            \"hostname\": self.host,\n            \"port\": self.port,\n        }\n\n        if self.password is not None:\n            payload[\"password\"] = self.password\n\n        self.logger.debug(f\"Growl Registration Payload: {payload!s}\")\n        self.growl = gntp.notifier.GrowlNotifier(**payload)\n\n        try:\n            self.growl.register()\n\n        except gntp.errors.NetworkError:\n            msg = (\n                \"A network error error occurred registering \"\n                f\"with Growl at {self.host}.\"\n            )\n            self.logger.warning(msg)\n            return False\n\n        except gntp.errors.ParseError:\n            msg = (\n                \"A parsing error error occurred registering \"\n                f\"with Growl at {self.host}.\"\n            )\n            self.logger.warning(msg)\n            return False\n\n        except gntp.errors.AuthError:\n            msg = (\n                \"An authentication error error occurred registering \"\n                f\"with Growl at {self.host}.\"\n            )\n            self.logger.warning(msg)\n            return False\n\n        except gntp.errors.UnsupportedError:\n            msg = (\n                \"An unsupported error occurred registering with \"\n                f\"Growl at {self.host}.\"\n            )\n            self.logger.warning(msg)\n            return False\n\n        self.logger.debug(\"Growl server registration completed successfully.\")\n\n        # Return our state\n        return True\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Growl Notification.\"\"\"\n        # Register ourselves with the server if we haven't done so already\n        if not self.growl and not self.register():\n            # We failed to register\n            return False\n\n        icon = None\n        if self.version >= 2:\n            # URL Based\n            icon = (\n                None if not self.include_image else self.image_url(notify_type)\n            )\n\n        else:\n            # Raw\n            icon = (\n                None if not self.include_image else self.image_raw(notify_type)\n            )\n\n        payload = {\n            \"noteType\": self.growl_notification_type,\n            \"title\": title,\n            \"description\": body,\n            \"icon\": icon is not None,\n            \"sticky\": self.sticky,\n            \"priority\": self.priority,\n        }\n        self.logger.debug(f\"Growl Payload: {payload!s}\")\n\n        # Update icon of payload to be raw data; this is intentionally done\n        # here after we spit the debug message above (so we don't try to\n        # print the binary contents of an image\n        payload[\"icon\"] = icon\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            # Perform notification\n            response = self.growl.notify(**payload)\n            if not isinstance(response, bool):\n                self.logger.warning(\n                    \"Growl notification failed to send with response:\"\n                    f\" {response!s}\",\n                )\n\n            else:\n                self.logger.info(\"Sent Growl notification.\")\n\n        except gntp.errors.BaseError as e:\n            # Since Growl servers listen for UDP broadcasts, it's possible\n            # that you will never get to this part of the code since there is\n            # no acknowledgement as to whether it accepted what was sent to it\n            # or not.\n\n            # However, if the host/server is unavailable, you will get to this\n            # point of the code.\n            self.logger.warning(\n                \"A Connection error occurred sending Growl \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Growl Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port if self.port else self.default_port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"sticky\": \"yes\" if self.sticky else \"no\",\n            \"priority\": (\n                GROWL_PRIORITIES[self.template_args[\"priority\"][\"default\"]]\n                if self.priority not in GROWL_PRIORITIES\n                else GROWL_PRIORITIES[self.priority]\n            ),\n            \"version\": self.version,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        auth = \"\"\n        if self.user:\n            # The growl password is stored in the user field\n            auth = \"{password}@\".format(\n                password=self.pprint(\n                    self.user, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n\n        return \"{schema}://{auth}{hostname}{port}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == self.default_port\n                else f\":{self.port}\"\n            ),\n            params=NotifyGrowl.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        version = None\n        if \"version\" in results[\"qsd\"] and len(results[\"qsd\"][\"version\"]):\n            # Allow the user to specify the version of the protocol to use.\n            try:\n                version = int(\n                    NotifyGrowl.unquote(results[\"qsd\"][\"version\"])\n                    .strip()\n                    .split(\".\")[0]\n                )\n\n            except (AttributeError, IndexError, TypeError, ValueError):\n                NotifyGrowl.logger.warning(\n                    'An invalid Growl version of \"{}\" was specified and will '\n                    \"be ignored.\".format(results[\"qsd\"][\"version\"])\n                )\n                pass\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyGrowl.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        # Because of the URL formatting, the password is actually where the\n        # username field is. For this reason, we just preform this small hack\n        # to make it (the URL) conform correctly. The following strips out the\n        # existing password entry (if exists) so that it can be swapped with\n        # the new one we specify.\n        if results.get(\"password\", None) is None:\n            results[\"password\"] = results.get(\"user\", None)\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyGrowl.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # Include images with our message\n        results[\"sticky\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"sticky\", NotifyGrowl.template_args[\"sticky\"][\"default\"]\n            )\n        )\n\n        # Set our version\n        if version:\n            results[\"version\"] = version\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/guilded.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this to work correctly you need to create a webhook. To do this just\n# click on the little gear icon next to the channel you're part of. From\n# here you'll be able to access the Webhooks menu and create a new one.\n#\n#  When you've completed, you'll get a URL that looks a little like this:\n#  https://media.guilded.gg/webhooks/417429632418316298/\\\n#         JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js\n#\n#  Simplified, it looks like this:\n#     https://media.guilded.gg/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n#\n#  This plugin will simply work using the url of:\n#     guilded://WEBHOOK_ID/WEBHOOK_TOKEN\n#\n# API Documentation on Webhooks:\n#    - https://discord.com/developers/docs/resources/webhook\n#\n\nimport re\n\n# Import namespace so the class won't conflict with the actual Notify object\nfrom . import discord\n\n\nclass NotifyGuilded(discord.NotifyDiscord):\n    \"\"\"A wrapper to Guilded Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Guilded\"\n\n    # The services URL\n    service_url = \"https://guilded.gg/\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/guilded/\"\n\n    # The default secure protocol\n    secure_protocol = \"guilded\"\n\n    # Guilded Webhook\n    notify_url = \"https://media.guilded.gg/webhooks\"\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://media.guilded.gg/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://(media\\.)?guilded\\.gg/webhooks/\"\n            # a UUID, but we do we really need to be _that_ picky?\n            r\"(?P<webhook_id>[-0-9a-f]+)/\"\n            r\"(?P<webhook_token>[A-Z0-9_-]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyGuilded.parse_url(\n                \"{schema}://{webhook_id}/{webhook_token}/{params}\".format(\n                    schema=NotifyGuilded.secure_protocol,\n                    webhook_id=result.group(\"webhook_id\"),\n                    webhook_token=result.group(\"webhook_token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/home_assistant.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# You must generate a \"Long-Lived Access Token\". This can be done from your\n# Home Assistant Profile page.\n\nfrom json import dumps\nfrom uuid import uuid4\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyHomeAssistant(NotifyBase):\n    \"\"\"A wrapper for Home Assistant Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"HomeAssistant\"\n\n    # The services URL\n    service_url = \"https://www.home-assistant.io/\"\n\n    # Insecure Protocol Access\n    protocol = \"hassio\"\n\n    # Secure Protocol\n    secure_protocol = \"hassios\"\n\n    # Default to Home Assistant Default Insecure port of 8123 instead of 80\n    default_insecure_port = 8123\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/homeassistant/\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{accesstoken}\",\n        \"{schema}://{host}:{port}/{accesstoken}\",\n        \"{schema}://{user}@{host}/{accesstoken}\",\n        \"{schema}://{user}@{host}:{port}/{accesstoken}\",\n        \"{schema}://{user}:{password}@{host}/{accesstoken}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{accesstoken}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"accesstoken\": {\n                \"name\": _(\"Long-Lived Access Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"nid\": {\n                # Optional Unique Notification ID\n                \"name\": _(\"Notification ID\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, accesstoken, nid=None, **kwargs):\n        \"\"\"Initialize Home Assistant Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.fullpath = kwargs.get(\"fullpath\", \"\")\n\n        if not (self.secure or self.port):\n            # Use default insecure port\n            self.port = self.default_insecure_port\n\n        # Long-Lived Access token (generated from User Profile)\n        self.accesstoken = validate_regex(accesstoken)\n        if not self.accesstoken:\n            msg = (\n                \"An invalid Home Assistant Long-Lived Access Token \"\n                f\"({accesstoken}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # An Optional Notification Identifier\n        self.nid = None\n        if nid:\n            self.nid = validate_regex(nid, *self.template_args[\"nid\"][\"regex\"])\n            if not self.nid:\n                msg = (\n                    \"An invalid Home Assistant Notification Identifier \"\n                    f\"({nid}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Sends Message.\"\"\"\n\n        # Prepare our persistent_notification.create payload\n        payload = {\n            \"title\": title,\n            \"message\": body,\n            # Use a unique ID so we don't over-write the last message\n            # we posted. Otherwise use the notification id specified\n            \"notification_id\": self.nid if self.nid else str(uuid4()),\n        }\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.accesstoken}\",\n        }\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        url += (\n            self.fullpath.rstrip(\"/\")\n            + \"/api/services/persistent_notification/create\"\n        )\n\n        self.logger.debug(\n            \"Home Assistant POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Home Assistant Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyHomeAssistant.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Home Assistant notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Home Assistant notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Home Assistant \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            (\n                self.port\n                if self.port\n                else (443 if self.secure else self.default_insecure_port)\n            ),\n            self.fullpath.rstrip(\"/\"),\n            self.accesstoken,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {}\n        if self.nid:\n            params[\"nid\"] = self.nid\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyHomeAssistant.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyHomeAssistant.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else self.default_insecure_port\n\n        url = (\n            \"{schema}://{auth}{hostname}{port}{fullpath}\"\n            \"{accesstoken}/?{params}\"\n        )\n\n        return url.format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if not self.port or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=(\n                \"/\"\n                if not self.fullpath\n                else \"/{}/\".format(\n                    NotifyHomeAssistant.quote(\n                        self.fullpath.strip(\"/\"), safe=\"/\"\n                    )\n                )\n            ),\n            accesstoken=self.pprint(self.accesstoken, privacy, safe=\"\"),\n            params=NotifyHomeAssistant.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our Long-Lived Access Token\n        if \"accesstoken\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"accesstoken\"]\n        ):\n            results[\"accesstoken\"] = NotifyHomeAssistant.unquote(\n                results[\"qsd\"][\"accesstoken\"]\n            )\n\n        else:\n            # Acquire our full path\n            fullpath = NotifyHomeAssistant.split_path(results[\"fullpath\"])\n\n            # Otherwise pop the last element from our path to be it\n            results[\"accesstoken\"] = fullpath.pop() if fullpath else None\n\n            # Re-assemble our full path\n            results[\"fullpath\"] = \"/\" + \"/\".join(fullpath) if fullpath else \"\"\n\n        # Allow the specification of a unique notification_id so that\n        # it will always replace the last one sent.\n        if \"nid\" in results[\"qsd\"] and len(results[\"qsd\"][\"nid\"]):\n            results[\"nid\"] = NotifyHomeAssistant.unquote(results[\"qsd\"][\"nid\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/httpsms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this service you will need a httpSMS account\n# You will need credits (new accounts start with a few)\n#     https://httpsms.com\nimport json\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyHttpSMS(NotifyBase):\n    \"\"\"A wrapper for HttpSMS Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"httpSMS\"\n\n    # The services URL\n    service_url = \"https://httpsms.com\"\n\n    # All notification requests are secure\n    secure_protocol = \"httpsms\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/httpsms/\"\n\n    # HttpSMS uses the http protocol with JSON requests\n    notify_url = \"https://api.httpsms.com/v1/messages/send\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}@{from_phone}\",\n        \"{schema}://{apikey}@{from_phone}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n                \"required\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"key\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"from\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n        },\n    )\n\n    def __init__(self, apikey=None, source=None, targets=None, **kwargs):\n        \"\"\"Initialize HttpSMS Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_phone_no(source)\n        if not result:\n            msg = (\n                f\"The Account (From) Phone # specified ({source}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Tidy source\n        self.source = result[\"full\"]\n\n        # Parse our targets\n        self.targets = []\n\n        has_error = False\n        for target in parse_phone_no(targets):\n            # Parse each phone number we found\n            result = is_phone_no(target)\n            if result:\n                self.targets.append(result[\"full\"])\n                continue\n\n            has_error = True\n            self.logger.warning(\n                f\"Dropped invalid phone # ({target}) specified.\",\n            )\n\n        if not targets and not has_error:\n            # Default the SMS Message to ourselves\n            self.targets.append(self.source)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform HttpSMS Notification.\"\"\"\n\n        if not self.targets:\n            # We have nothing to notify\n            self.logger.warning(\"There are no HttpSMS targets to notify\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"x-api-key\": self.apikey,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {\n            # The To gets populated in the loop below\n            \"from\": \"+\" + self.source,\n            \"to\": None,\n            \"content\": body,\n        }\n\n        # Prepare our targets\n        targets = list(self.targets)\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our user\n            payload[\"to\"] = \"+\" + target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"HttpSMS POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"HttpSMS Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=json.dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    # set up our status code to use\n                    status_code = r.status_code\n\n                    self.logger.warning(\n                        \"Failed to send HttpSMS notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent HttpSMS notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending HttpSMS: to %s \",\n                    target,\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.source, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Prepare our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        # A nice way of cleaning up the URL length a bit\n        targets = (\n            []\n            if len(self.targets) == 1 and self.targets[0] == self.source\n            else self.targets\n        )\n\n        return \"{schema}://{apikey}@{source}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            source=self.source,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyHttpSMS.quote(f\"{x}\", safe=\"+\") for x in targets]\n            ),\n            params=NotifyHttpSMS.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n\n        return len(self.targets) if self.targets else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our API Key\n        results[\"apikey\"] = NotifyHttpSMS.unquote(results[\"user\"])\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyHttpSMS.unquote(results[\"qsd\"][\"from\"])\n\n            # hostname will also be a target in this case\n            results[\"targets\"] = [\n                *NotifyHttpSMS.parse_phone_no(results[\"host\"]),\n                *NotifyHttpSMS.split_path(results[\"fullpath\"]),\n            ]\n\n        else:\n            # store our source\n            results[\"source\"] = NotifyHttpSMS.unquote(results[\"host\"])\n\n            # store targets\n            results[\"targets\"] = NotifyHttpSMS.split_path(results[\"fullpath\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyHttpSMS.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            results[\"apikey\"] = NotifyHttpSMS.unquote(results[\"qsd\"][\"key\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/ifttt.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this plugin to work, you need to add the Maker applet to your profile\n# Simply visit https://ifttt.com/search and search for 'Webhooks'\n# Or if you're signed in, click here: https://ifttt.com/maker_webhooks\n# and click 'Connect'\n#\n# You'll want to visit the settings of this Applet and pay attention to the\n# URL. For example, it might look like this:\n#               https://maker.ifttt.com/use/a3nHB7gA9TfBQSqJAHklod\n#\n# In the above example a3nHB7gA9TfBQSqJAHklod becomes your {webhook_id}\n# You will need this to make this notification work correctly\n#\n# For each event you create you will assign it a name (this will be known as\n# the {event} when building your URL.\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyIFTTT(NotifyBase):\n    \"\"\"A wrapper for IFTTT Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"IFTTT\"\n\n    # The services URL\n    service_url = \"https://ifttt.com/\"\n\n    # The default protocol\n    secure_protocol = \"ifttt\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/ifttt/\"\n\n    # Even though you'll add 'Ingredients' as {{ Value1 }} to your Applets,\n    # you must use their lowercase value in the HTTP POST.\n    ifttt_default_key_prefix = \"value\"\n\n    # The default IFTTT Key to use when mapping the title text to the IFTTT\n    # event. The idea here is if someone wants to over-ride the default and\n    # change it to another Ingredient Name (in 2018, you were limited to have\n    # value1, value2, and value3).\n    ifttt_default_title_key = \"value1\"\n\n    # The default IFTTT Key to use when mapping the body text to the IFTTT\n    # event. The idea here is if someone wants to over-ride the default and\n    # change it to another Ingredient Name (in 2018, you were limited to have\n    # value1, value2, and value3).\n    ifttt_default_body_key = \"value2\"\n\n    # The default IFTTT Key to use when mapping the body text to the IFTTT\n    # event. The idea here is if someone wants to over-ride the default and\n    # change it to another Ingredient Name (in 2018, you were limited to have\n    # value1, value2, and value3).\n    ifttt_default_type_key = \"value3\"\n\n    # IFTTT uses the http protocol with JSON requests\n    notify_url = (\n        \"https://maker.ifttt.com/trigger/{event}/with/key/{webhook_id}\"\n    )\n\n    # Define object templates\n    templates = (\"{schema}://{webhook_id}/{events}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"webhook_id\": {\n                \"name\": _(\"Webhook ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"events\": {\n                \"name\": _(\"Events\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"events\",\n            },\n        },\n    )\n\n    # Define our token control\n    template_kwargs = {\n        \"add_tokens\": {\n            \"name\": _(\"Add Tokens\"),\n            \"prefix\": \"+\",\n        },\n        \"del_tokens\": {\n            \"name\": _(\"Remove Tokens\"),\n            \"prefix\": \"-\",\n        },\n    }\n\n    def __init__(\n        self, webhook_id, events, add_tokens=None, del_tokens=None, **kwargs\n    ):\n        \"\"\"Initialize IFTTT Object.\n\n        add_tokens can optionally be a dictionary of key/value pairs that you\n        want to include in the IFTTT post to the server.\n\n        del_tokens can optionally be a list/tuple/set of tokens that you want\n        to eliminate from the IFTTT post.  There isn't much real functionality\n        to this one unless you want to remove reference to Value1, Value2,\n        and/or Value3\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # Webhook ID (associated with project)\n        self.webhook_id = validate_regex(webhook_id)\n        if not self.webhook_id:\n            msg = f\"An invalid IFTTT Webhook ID ({webhook_id}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our Events we wish to trigger\n        self.events = parse_list(events)\n        if not self.events:\n            msg = \"You must specify at least one event you wish to trigger on.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Tokens to include in post\n        self.add_tokens = {}\n        if add_tokens:\n            self.add_tokens.update(add_tokens)\n\n        # Tokens to remove\n        self.del_tokens = []\n        if del_tokens is not None:\n            if isinstance(del_tokens, (list, tuple, set)):\n                self.del_tokens = del_tokens\n\n            elif isinstance(del_tokens, dict):\n                # Convert the dictionary into a list\n                self.del_tokens = set(del_tokens.keys())\n\n            else:\n                msg = (\n                    f\"del_token must be a list; {type(del_tokens)!s} was\"\n                    \" provided\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform IFTTT Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # prepare JSON Object\n        payload = {\n            self.ifttt_default_title_key: title,\n            self.ifttt_default_body_key: body,\n            self.ifttt_default_type_key: notify_type.value,\n        }\n\n        # Add any new tokens expected (this can also potentially override\n        # any entries defined above)\n        payload.update(self.add_tokens)\n\n        # Eliminate fields flagged for removal otherwise ensure all tokens are\n        # lowercase since that is what the IFTTT server expects from us.\n        payload = {\n            x.lower(): y\n            for x, y in payload.items()\n            if x not in self.del_tokens\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Create a copy of our event lit\n        events = list(self.events)\n\n        while len(events):\n\n            # Retrive an entry off of our event list\n            event = events.pop(0)\n\n            # URL to transmit content via\n            url = self.notify_url.format(\n                webhook_id=self.webhook_id,\n                event=event,\n            )\n\n            self.logger.debug(\n                \"IFTTT POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"IFTTT Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                self.logger.debug(\n                    f\"IFTTT HTTP response headers: {r.headers!r}\"\n                )\n                self.logger.debug(f\"IFTTT HTTP response body: {r.content!r}\")\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyIFTTT.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send IFTTT notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            event,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent IFTTT notification to {event}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending IFTTT:{event} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.webhook_id)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        # Store any new key/value pairs added to our list\n        params.update({f\"+{k}\": v for k, v in self.add_tokens})\n        params.update({f\"-{k}\": \"\" for k in self.del_tokens})\n\n        return \"{schema}://{webhook_id}@{events}/?{params}\".format(\n            schema=self.secure_protocol,\n            webhook_id=self.pprint(self.webhook_id, privacy, safe=\"\"),\n            events=\"/\".join(\n                [NotifyIFTTT.quote(x, safe=\"\") for x in self.events]\n            ),\n            params=NotifyIFTTT.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.events)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Our API Key is the hostname if no user is specified\n        results[\"webhook_id\"] = (\n            results[\"user\"] if results[\"user\"] else results[\"host\"]\n        )\n\n        # Unquote our API Key\n        results[\"webhook_id\"] = NotifyIFTTT.unquote(results[\"webhook_id\"])\n\n        # Parse our add_token and del_token arguments (if specified)\n        results[\"add_token\"] = results[\"qsd+\"]\n        results[\"del_token\"] = results[\"qsd-\"]\n\n        # Our Event\n        results[\"events\"] = []\n        if results[\"user\"]:\n            # If a user was defined, then the hostname is actually a event\n            # too\n            results[\"events\"].append(NotifyIFTTT.unquote(results[\"host\"]))\n\n        # Now fetch the remaining tokens\n        results[\"events\"].extend(NotifyIFTTT.split_path(results[\"fullpath\"]))\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"events\"] += NotifyIFTTT.parse_list(results[\"qsd\"][\"to\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://maker.ifttt.com/use/WEBHOOK_ID/EVENT_ID\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://maker\\.ifttt\\.com/use/\"\n            r\"(?P<webhook_id>[A-Z0-9_-]+)\"\n            r\"((?P<events>(/[A-Z0-9_-]+)+))?\"\n            r\"/?(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyIFTTT.parse_url(\n                \"{schema}://{webhook_id}{events}{params}\".format(\n                    schema=NotifyIFTTT.secure_protocol,\n                    webhook_id=result.group(\"webhook_id\"),\n                    events=(\n                        \"\"\n                        if not result.group(\"events\")\n                        else \"@{}\".format(result.group(\"events\"))\n                    ),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/irc/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"IRC Notifications.\"\"\"\n\nfrom .base import NotifyIRC\n\n__all__ = [\n    \"NotifyIRC\",\n]\n"
  },
  {
    "path": "apprise/plugins/irc/base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"IRC Notifications.\n\nThis is simplified IRC client designed for notification delivery.\nIt focuses on reliability and predictable behaviour, not full IRC features.\n\nURL formats (examples):\n  - irc://hostname/#channel/@user\n  - irc://user@hostname/#channel\n  - irc://user:password@hostname/#channel\n  - ircs://hostname/#channel  (TLS, default port 6697)\n\nTargets:\n  - Channels are specified as #channel\n  - Users are specified as @nickname\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom itertools import chain\nimport re\nfrom typing import Any, Optional\n\nfrom ...common import NotifyType\nfrom ...locale import gettext_lazy as _\nfrom ...url import PrivacyMode\nfrom ...utils.parse import parse_bool, parse_list\nfrom ...utils.socket import AppriseSocketError\nfrom ..base import NotifyBase\nfrom . import templates\nfrom .client import IRCClient\nfrom .protocol import IRC_AUTH_MODES, IRCAuthMode, normalise_channel\n\nIS_USER = re.compile(r\"^\\s*(@|%40)?(?P<user>[^ \\t\\r\\n@#]+)$\", re.I)\n\nIS_CHANNEL = re.compile(\n    r\"^\\s*(#|%23)\"\n    r\"(?P<channel>[^ \\t\\r\\n@#:]+)\"\n    r\"(?::(?P<key>[^ \\t\\r\\n]+))?\\s*$\",\n    re.I,\n)\n\n\nclass NotifyIRC(NotifyBase):\n    \"\"\"A wrapper to IRC servers using TCP or TLS.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"IRC\"\n\n    # The services URL\n    service_url = \"https://ircv3.net/\"\n\n    # The default insecure protocol\n    protocol = \"irc\"\n\n    # The default secure protocol\n    secure_protocol = \"ircs\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/irc/\"\n\n    # RFC 2812 maximum IRC message length is 512 bytes including CRLF.\n    # Keep a conservative payload budget to accommodate prefix overhead.\n    body_maxlen = 380\n\n    # IRC is not fast... there is a lot of handshaking that takes place\n    # between us and the remote server. During development of this plugin\n    # it took on average 18-22s to register with #EFnet; setting the value\n    # to 30.0s to be conservative with others as their mileage may vary\n    irc_register_timeout = 30.0\n\n    # Avoid flooding\n    request_rate_per_sec = 0.02\n\n    # Title is prepended to body\n    title_maxlen = 0\n\n    templates = (\n        \"{schema}://{host}/{targets}\",\n        \"{schema}://{host}:{port}/{targets}\",\n        \"{schema}://{user}@{host}/{targets}\",\n        \"{schema}://{user}@{host}:{port}/{targets}\",\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n    )\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"User\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"name\": {\"name\": _(\"Real Name\"), \"type\": \"string\"},\n            \"nick\": {\"name\": _(\"Nickname\"), \"type\": \"string\"},\n            \"join\": {\n                \"name\": _(\"Join Channels\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"mode\": {\n                \"name\": _(\"Auth Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": IRC_AUTH_MODES,\n                \"default\": IRCAuthMode.SERVER,\n            },\n            \"to\": {\"alias_of\": \"targets\"},\n        },\n    )\n\n    def __init__(\n        self,\n        targets: Optional[list[str]] = None,\n        name: Optional[str] = None,\n        join: Optional[bool] = None,\n        nick: Optional[str] = None,\n        mode: Optional[str] = None,\n        **kwargs: Any,\n    ) -> None:\n\n        super().__init__(**kwargs)\n\n        # Join Channel\n        self.join = (\n            parse_bool(join, self.template_args[\"join\"][\"default\"])\n            if join is not None\n            else self.template_args[\"join\"][\"default\"]\n        )\n\n        self.nickname = (nick or \"\").strip() or (self.user or \"\").strip()\n\n        # Initialized value (as it is used in apply_irc_defaults())\n        self.auth_mode = self.template_args[\"mode\"][\"default\"]\n\n        # Apply template defaults only where the user did not supply values\n        self.apply_irc_defaults(**kwargs)\n\n        if isinstance(mode, str) and mode.strip():\n            self.auth_mode = mode.strip().lower()\n            self.auth_mode = next(\n                (a for a in IRC_AUTH_MODES\n                 if a.startswith(self.auth_mode)), None\n            )\n            if self.auth_mode not in IRC_AUTH_MODES:\n                msg = f\"The IRC auth mode specified ({mode}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        self.fullname = (name or \"\").strip()\n\n        # For storing our channels and users to message\n        self.channels: dict[str, Optional[str]] = {}\n        self.users: list[str] = []\n\n        # Set our timeouts\n        srt = float(self.socket_read_timeout or 0.0)\n        self.join_timeout = max(6.0, min(12.0, srt or 6.0))\n        self.send_timeout = max(4.0, min(10.0, srt or 4.0))\n\n        # Identify our targets\n        self.targets = []\n        for target in parse_list(targets):\n            match = IS_CHANNEL.match(target)\n            if match:\n                channel = match.group(\"channel\")\n                key = match.group(\"key\")\n                self.channels[channel] = key\n                continue\n\n            match = IS_USER.match(target)\n            if match:\n                self.users.append(match.group(\"user\"))\n                continue\n\n            self.logger.warning(\"Dropped invalid IRC target (%s).\", target)\n\n    def apply_irc_defaults(self, port=None, **kwargs):\n        \"\"\"\n        A function that prefills defaults based on the irc details\n        provided.\n        \"\"\"\n        if self.port:\n            # IRC port was explicitly specified, therefore it is assumed\n            # the caller knows what they're doing and is intentionally\n            # overriding any smart defaults that might otherwise be applied.\n            return\n\n        for i in range(len(templates.IRC_TEMPLATES)):  # pragma: no branch\n            self.logger.trace(\n                \"Scanning %s against %s\",\n                self.host, templates.IRC_TEMPLATES[i][0])\n\n            match = templates.IRC_TEMPLATES[i][1].match(self.host)\n            if match:\n                self.logger.info(\n                    f\"Applying {templates.IRC_TEMPLATES[i][0]} Defaults\")\n\n                # the secure flag can not be altered if defined in the template\n                self.secure = templates.IRC_TEMPLATES[i][2].get(\n                    \"secure\", self.secure,\n                )\n\n                # store default port\n                self.port = templates.IRC_TEMPLATES[i][2].get(\n                    \"port\", self.port,\n                )\n\n                self.auth_mode = templates.IRC_TEMPLATES[i][2].get(\n                    \"mode\", self.auth_mode,\n                )\n                break\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        attach: Any = None,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Send a notification to IRC targets.\"\"\"\n\n        if not (self.channels or self.users):\n            self.logger.warning(\"No IRC targets specified.\")\n            return False\n\n        # prepare ourselves a nickname\n        nickname = self.nickname or IRCClient.nick_generation(\n            prefix=self.app_id,\n        )\n\n        self.throttle()\n\n        client = IRCClient(\n            host=self.host,\n            nickname=nickname,\n            fullname=self.fullname or self.app_desc,\n            port=self.port,\n            secure=self.secure,\n            verify=self.verify_certificate,\n            timeout=self.socket_read_timeout,\n            # In ZNC mode, authentication is performed against the bouncer\n            # itself. ZNC configurations expect the PASS line to include the\n            # username.\n            password=self.password if self.auth_mode != IRCAuthMode.ZNC\n            else f\"{self.user}:{self.password}\",\n            auth_mode=self.auth_mode,\n            nick_generator=IRCClient.nick_generation,\n        )\n\n        try:\n            client.connect()\n            client.register(\n                timeout=self.irc_register_timeout,\n                prefix=self.app_id,\n            )\n\n            # ZNC operates as a bouncer, so perform a quick sanity check that\n            # the connection is alive before issuing commands.\n            if self.auth_mode == IRCAuthMode.ZNC \\\n                    and not client.check_connection(\n                    timeout=min(5.0, float(self.send_timeout or 5.0))):\n                raise AppriseSocketError(\"ZNC connection check failed\")\n\n            message = body if not title else f\"{title} {body}\".strip()\n\n            for c, key in self.channels.items():\n                chan = normalise_channel(c)\n                if self.join or key:\n                    client.join(\n                        channel=chan,\n                        key=key,\n                        timeout=self.join_timeout,\n                        prefix=self.app_id,\n                    )\n\n                client.privmsg(\n                    target=chan,\n                    message=message,\n                    timeout=self.send_timeout,\n                )\n                self.logger.info(\n                    \"Sent IRC notification to #%s as @%s\",\n                    c,\n                    client.nickname,\n                )\n\n            for u in self.users:\n                target = u.lstrip(\"@\")\n                client.privmsg(\n                    target=target,\n                    message=message,\n                    timeout=self.send_timeout,\n                )\n                self.logger.info(\n                    \"Sent IRC notification to @%s as @%s\",\n                    u,\n                    client.nickname,\n                )\n\n            client.quit(message=self.app_desc, timeout=self.send_timeout)\n            return True\n\n        except (AppriseSocketError, OSError, TimeoutError) as e:\n            self.logger.warning(\n                \"Failed to send IRC notification to %s as @%s.\",\n                self.host,\n                nickname,\n            )\n            self.logger.debug(\"IRC Exception: %s\", e)\n            return False\n\n        finally:\n            client.close()\n\n    @property\n    def url_identifier(\n            self) -> tuple[str, Optional[str], Optional[str], Optional[str]]:\n        \"\"\"Return the pieces that uniquely identify this configuration.\"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.host,\n            self.user,\n            self.password,\n        )\n\n    def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:\n        \"\"\"Return the URL representation of this notification.\"\"\"\n        params: dict[str, Any] = {\n            \"verify\": self.verify_certificate,\n            \"join\": self.join,\n        }\n\n        if self.auth_mode and self.auth_mode != IRCAuthMode.SERVER:\n            params[\"mode\"] = self.auth_mode\n\n        if self.fullname:\n            params[\"name\"] = self.fullname\n\n        if self.nickname and self.nickname != (self.user or \"\"):\n            params[\"nick\"] = self.nickname\n\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=self.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password,\n                    privacy,\n                    mode=PrivacyMode.Secret,\n                    safe=\"\",\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(user=self.quote(self.user, safe=\"\"))\n\n        default_port = IRCClient.default_secure_port \\\n            if self.secure else IRCClient.default_insecure_port\n\n        port = self.port if isinstance(self.port, int) else (\n            IRCClient.default_secure_port\n            if self.secure else IRCClient.default_insecure_port\n        )\n\n        port = \"\" if port == default_port else f\":{port}\"\n\n        schema = self.secure_protocol if self.secure else self.protocol\n        return \"{schema}://{auth}{host}{port}/{targets}?{params}\".format(\n            schema=schema,\n            auth=auth,\n            host=self.host,\n            port=port,\n            targets=\"/\".join(chain(\n                [self.quote(f\"#{c}\" if not k else \"#{}:{}\".format(\n                    c,\n                    self.pprint(\n                        k,\n                        privacy,\n                        mode=PrivacyMode.Secret,\n                        safe=\"\")),\n                    safe=\"#\") for c, k in self.channels.items()],\n                [self.quote(f\"@{u}\", safe=\"@\")\n                 for u in self.users],\n            )),\n            params=self.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url: str) -> Optional[dict[str, Any]]:\n        \"\"\"Parse an IRC URL into constructor arguments.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            return None\n\n        results[\"targets\"] = []\n\n        if \"host\" in results[\"qsd\"] and len(results[\"qsd\"][\"host\"]):\n            # a host was defined which means the first entry is actually one\n            # of our targets\n            results[\"targets\"].append(NotifyIRC.unquote(results[\"host\"]))\n\n        # Store remaining targets\n        results[\"targets\"].extend(NotifyIRC.split_path(results[\"fullpath\"]))\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyIRC.parse_list(\n                NotifyIRC.unquote(results[\"qsd\"][\"to\"])\n            )\n\n        # Get Join Channel Flag\n        results[\"join\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"join\", NotifyIRC.template_args[\"join\"][\"default\"]\n            )\n        )\n\n        # Get our IRC Name\n        if \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n            results[\"name\"] = NotifyIRC.unquote(results[\"qsd\"][\"name\"])\n\n        if \"nick\" in results[\"qsd\"] and len(results[\"qsd\"][\"nick\"]):\n            results[\"nick\"] = NotifyIRC.unquote(results[\"qsd\"][\"nick\"])\n\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            results[\"mode\"] = NotifyIRC.unquote(results[\"qsd\"][\"mode\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/irc/client.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"A minimal IRC client for notification delivery.\"\"\"\n\nfrom __future__ import annotations\n\nfrom collections import deque\nimport logging\nimport random\nimport string\nimport time\nfrom typing import Callable, Optional, Union\n\nfrom ...logger import logger\nfrom ...utils.socket import AppriseSocketError, SocketTransport\nfrom .protocol import (\n    IRCAuthMode,\n    is_ping,\n    normalise_channel,\n    parse_irc_line,\n    ping_payload,\n)\nfrom .state import IRCActionKind, IRCContext, IRCStateMachine\n\nNickGenerator = Callable[[str, int, int], str]\n\n\nclass IRCClient:\n    \"\"\"Socket-driven IRC Client.\"\"\"\n\n    # IRC Default Ports\n    default_insecure_port = 6667\n    default_secure_port = 6697\n\n    # When we're blocking and awaiting the server, we increase the\n    # frequency to poll for an update.\n    pump_interval = 0.75\n\n    # EFNet only allows nicknames to be 9 characters, default to that.\n    nickname_max_length = 9\n\n    # How many times we retry on 432/433 before failing.\n    nickname_collision_max = 3\n\n    def __init__(\n        self,\n        host: str,\n        nickname: str,\n        fullname: str,\n        port: Optional[int] = None,\n        secure: bool = False,\n        verify: bool = True,\n        timeout: Optional[float] = None,\n        password: Optional[str] = None,\n        auth_mode: str = IRCAuthMode.SERVER,\n        nick_generator: Optional[NickGenerator] = None,\n        nick_length: Optional[int] = None,\n    ) -> None:\n\n        # Detect port if not set\n        port = port if port is not None else (\n            self.default_secure_port\n            if secure else self.default_insecure_port\n        )\n\n        self.transport = SocketTransport(\n            host,\n            port,\n            secure=secure,\n            verify=verify,\n            timeout=timeout,\n        )\n\n        self._nick_generator = nick_generator\n        self._nick_length = (\n            int(nick_length)\n            if nick_length else int(self.nickname_max_length)\n        )\n        self._nick_collision = 0\n\n        self.auth_mode = auth_mode\n\n        ctx = IRCContext(\n            desired_nick=nickname,\n            accepted_nick=nickname,\n            fullname=fullname,\n            password=password,\n        )\n        self.sm = IRCStateMachine(ctx)\n        self._out_queue = deque()  # type: Deque[bytes]\n        self._inbuf = bytearray()\n\n    @property\n    def nickname(self) -> str:\n        \"\"\"Returns the accepted nickname.\"\"\"\n        return self.sm.ctx.accepted_nick\n\n    def connect(self) -> None:\n        self.transport.connect()\n\n    def close(self) -> None:\n        self.transport.close()\n\n    def _queue(self, line: str) -> None:\n        \"\"\"\n        Queues message for outbound delivery to the IRC Server\n        \"\"\"\n        payload = (line + \"\\r\\n\").encode(\"utf-8\", errors=\"replace\")\n        self._out_queue.append(payload)\n\n    def _write(self, line: Union[str, bytes], deadline: float) -> None:\n        \"\"\"Write content directly to IRC.\"\"\"\n        remaining = max(0.0, deadline - time.monotonic())\n        if remaining <= 0.0:\n            raise TimeoutError(\"timeout while writing IRC commands\")\n\n        payload = (line + \"\\r\\n\").encode(\"utf-8\", errors=\"replace\") \\\n            if isinstance(line, str) else line\n        self.transport.write(payload, flush=True, timeout=remaining)\n        if logger.isEnabledFor(logging.TRACE):\n            logger.trace(\n                \"IRC write: %s\", payload.rstrip(b\"\\r\")\n                .decode(\"utf-8\", errors=\"replace\").rstrip())\n\n    def _flush(self, deadline: float) -> None:\n        \"\"\"Flush all queued information to the IRC server.\"\"\"\n        while self._out_queue:\n            self._write(self._out_queue[0], deadline=deadline)\n            self._out_queue.popleft()\n\n    def _read(self, deadline: float) -> Optional[str]:\n        \"\"\"\n        Read incoming content from IRC Server\n        \"\"\"\n        while True:\n            if b\"\\n\" in self._inbuf:\n                line, _, rest = self._inbuf.partition(b\"\\n\")\n                self._inbuf = bytearray(rest)\n                response = line.rstrip(b\"\\r\").decode(\"utf-8\", errors=\"replace\")\n                logger.trace(\"IRC read: %s\", response)\n                return response\n\n            remaining = max(0.0, deadline - time.monotonic())\n            if remaining <= 0.0:\n                logger.trace(\n                    \"IRC read timeout - deadline=%.2fs\", deadline)\n                return None\n\n            chunk = self.transport.read(4096, blocking=True, timeout=remaining)\n            if not chunk:\n                return None\n            self._inbuf.extend(chunk)\n\n    def _nickname_collision_handler(self, prefix: str) -> str:\n        if not self._nick_generator:\n            raise AppriseSocketError(\"Nickname collision and no generator\")\n\n        if self._nick_collision >= int(self.nickname_collision_max):\n            raise AppriseSocketError(\"Nickname is already in use\")\n\n        self._nick_collision += 1\n        self.sm.ctx.desired_nick = self._nick_generator(\n            prefix,\n            self._nick_length,\n            self._nick_collision,\n        )\n        return self.sm.ctx.desired_nick\n\n    def _tick(self, deadline: float) -> float:\n        remaining = max(0.0, deadline - time.monotonic())\n        if remaining <= 0.0:\n            return deadline\n        return time.monotonic() + min(self.pump_interval, remaining)\n\n    def _handshake(self, deadline: float, prefix: str) -> None:\n        while True:\n            line = self._read(deadline=deadline)\n            if not line:\n                # We've completed\n                return\n\n            msg = parse_irc_line(line)  # type: IRCMessage\n\n            if is_ping(msg):\n                self._write(f\"PONG :{ping_payload(msg)}\", deadline=deadline)\n                continue\n\n            if msg.numeric in (432, 433):\n                new_nick = self._nickname_collision_handler(prefix)\n                # Send immediately, do not queue.\n                self._write(\"NICK {}\".format(new_nick), deadline=deadline)\n                continue\n\n            for act in self.sm.on_message(msg):\n                if act.kind == IRCActionKind.FAIL and act.reason:\n                    raise AppriseSocketError(act.reason)\n                if act.kind == IRCActionKind.SEND and act.line:\n                    self._write(act.line, deadline=deadline)\n\n    def register(self, timeout: float, prefix: str) -> None:\n        \"\"\"Register with the IRC server, and optionally NickServ identify.\n\n        - SERVER mode: sends PASS during registration (if password provided)\n        - NICKSERV mode: does not send PASS, performs NickServ IDENTIFY after\n          registration completes\n        - NONE mode: no authentication is performed\n        \"\"\"\n        tl_start = time.time()\n        deadline = time.monotonic() + float(timeout)\n\n        # PASS during registration is only used for SERVER and ZNC.\n        if self.auth_mode not in (IRCAuthMode.SERVER, IRCAuthMode.ZNC):\n            self.sm.ctx.password = None\n\n        logger.trace(\"IRC registration started\")\n        for act in self.sm.start_registration():\n            if act.kind == IRCActionKind.SEND and act.line:\n                self._queue(act.line)\n\n        while time.monotonic() < deadline and not self.sm.ctx.registered:\n            self._flush(deadline)\n            self._handshake(self._tick(deadline), prefix=prefix)\n\n        if not self.sm.ctx.registered:\n            logger.trace(\n                \"IRC registration timeout - %.6fs elapsed\",\n                time.time() - tl_start,\n            )\n            raise TimeoutError(\"IRC registration timeout\")\n\n        logger.trace(\n            \"IRC registration completed in %.6fs\",\n            time.time() - tl_start,\n        )\n\n        # NickServ identify is only performed after we are registered, and only\n        # when explicitly requested via auth_mode.\n        if self.auth_mode == IRCAuthMode.NICKSERV:\n            self.identify(timeout=timeout)\n\n    def check_connection(self, timeout: float) -> bool:\n        \"\"\"Verify we can talk to the server by completing a PING/PONG.\"\"\"\n        deadline = time.monotonic() + float(timeout)\n        token = \"apprise\"\n\n        # Send a ping and wait until we observe a PONG carrying our token.\n        self._write(f\"PING :{token}\", deadline=deadline)\n\n        while time.monotonic() < deadline:\n            line = self._read(deadline=deadline)\n            if not line:\n                continue\n\n            msg = parse_irc_line(line)\n            if msg.command.upper() == \"PONG\":\n                # Some IRC servers/bouncers do not echo our token back\n                # reliably. Observing any PONG after issuing our PING is\n                # sufficient.\n                return True\n\n        return False\n\n    def join(\n        self,\n        channel: str,\n        timeout: float,\n        prefix: str,\n        key: Optional[str] = None,\n    ) -> None:\n\n        chan = normalise_channel(channel)\n        if chan in self.sm.ctx.joined:\n            # Nothing to do, we are already there.\n            return\n        deadline = time.monotonic() + float(timeout)\n\n        for act in self.sm.request_join(chan, key=key):\n            if act.kind == IRCActionKind.SEND and act.line:\n                self._queue(act.line)\n\n        while time.monotonic() < deadline and chan not in self.sm.ctx.joined:\n            self._flush(deadline)\n            self._handshake(self._tick(deadline), prefix=prefix)\n\n        if chan not in self.sm.ctx.joined:\n            logger.debug(\"IRC join confirmation not observed for %s\", chan)\n\n    def privmsg(self, target: str, message: str, timeout: float) -> None:\n        \"\"\"Handle the sending of private messages.\"\"\"\n        deadline = time.monotonic() + float(timeout)\n        self._queue(f\"PRIVMSG {target} :{message}\")\n        self._flush(deadline)\n        self._handshake(self._tick(deadline), prefix=\"\")\n\n    def identify(self, timeout: float) -> None:\n        \"\"\"Identify with NickServ after registration.\"\"\"\n        if not self.sm.ctx.password:\n            return\n\n        if self.auth_mode != IRCAuthMode.NICKSERV:\n            return\n\n        deadline = time.monotonic() + float(timeout)\n        self._queue(\n            \"PRIVMSG NickServ :IDENTIFY {}\".format(\n                self.sm.ctx.password\n            )\n        )\n        self._flush(deadline)\n        self._handshake(\n            self._tick(deadline),\n            prefix=\"\",\n        )\n\n    def quit(self, message: str, timeout: float) -> None:\n        deadline = time.monotonic() + float(timeout)\n        for act in self.sm.request_quit(message):\n            if act.kind == IRCActionKind.SEND and act.line:\n                self._queue(act.line)\n        self._flush(deadline)\n\n    @staticmethod\n    def nick_generation(\n            prefix: str, length: Optional[int] = None,\n            collision: int = 0) -> str:\n        \"\"\"Generate a nickname suitable for retry after collision.\"\"\"\n        if length is None:\n            # Default Assignment\n            length = IRCClient.nickname_max_length\n\n        base = \"{}\".format(prefix)[:length - 3].strip().lower()\n        charset = string.ascii_lowercase + string.digits + \"_\"\n        suffix = \"\".join(random.choice(charset) for _ in range(max(1, length)))\n        nick = \"{}{}\".format(base, suffix)\n        if collision:\n            nick = \"{}{}\".format(nick[: max(0, length - 1)], collision % 10)\n        return nick[:length]\n"
  },
  {
    "path": "apprise/plugins/irc/protocol.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"IRC protocol parsing helpers.\n\nThis module contains *pure* helpers used by the IRC client/state machine.\n\nDesign goals:\n- Be conservative: parse only what we need for reliability.\n- Avoid RFC rabbit holes: implement RFC 1459-ish behaviour for numerics,\n  PING/PONG, JOIN detection, and channel normalisation.\n- Keep parsing side-effect free: no state mutations happen here.\n\nTerminology refresher (IRC line shape, simplified):\n    [\":\" <prefix> <SPACE>] <command> <params> [\":\" <trailing>]\n\nExamples:\n    :server.example 001 nick :Welcome to the network\n    PING :123456\n    :nick!user@host JOIN :#channel\n    :server 366 nick #channel :End of /NAMES list.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import Optional\n\nfrom ...compat import dataclass_compat as dataclass\n\n\nclass IRCAuthMode:\n    \"\"\"IRC authentication mode.\n\n    The IRC plugin uses a small set of authentication strategies, selected\n    by URL parsing logic. These values are treated as constants and are used\n    by the client (connection setup) rather than by the parsing/state code.\n\n    NONE\n        No authentication.\n    SERVER\n        Use PASS <password> during registration.\n    NICKSERV\n        Authenticate after registration via NickServ IDENTIFY.\n    ZNC\n        Connect to a ZNC bouncer and presume registration is already handled.\n        In this mode, the client generally avoids emitting registration flows.\n    \"\"\"\n\n    # No authentication\n    NONE = \"none\"\n\n    # PASS <password> during registration\n    SERVER = \"server\"\n\n    # NickServ IDENTIFY after registration\n    NICKSERV = \"nickserv\"\n\n    # ZNC bouncer mode - connects to bouncer only - presumes registration\n    ZNC = \"znc\"\n\n\nIRC_AUTH_MODES = (\n    IRCAuthMode.ZNC,\n    IRCAuthMode.SERVER,\n    IRCAuthMode.NICKSERV,\n    IRCAuthMode.NONE,\n)\n\n\n@dataclass(frozen=True, slots=True)\nclass IRCMessage:\n    \"\"\"A parsed IRC line.\n\n    raw\n        The line as received (minus CRLF).\n    prefix\n        Optional prefix (nick/server). Examples:\n            server.example\n            nick!user@host\n    command\n        The IRC command, for example: PRIVMSG, JOIN, PING, or a numeric string.\n    params\n        A tuple of middle parameters (space separated).\n    trailing\n        The trailing parameter (after ' :'), which may contain spaces.\n\n    Notes on numerics:\n        Numeric replies are three digits as a string. This helper provides\n        a .numeric property that returns an int, or None when not numeric.\n    \"\"\"\n\n    raw: str\n    prefix: Optional[str]\n    command: str\n    params: tuple[str, ...]\n    trailing: Optional[str]\n\n    @property\n    def numeric(self) -> Optional[int]:\n        \"\"\"Return numeric reply code as int when command is a 3-digit\n        string.\"\"\"\n        if self.command.isdigit() and len(self.command) == 3:\n            return int(self.command)\n        return None\n\n\ndef parse_irc_line(line: str) -> IRCMessage:\n    \"\"\"Parse an IRC line into its components.\n\n    This is intentionally tolerant and small, but sufficient for:\n    - detecting PINGs (command == 'PING')\n    - reading common numeric replies (001, 376/422, 366, error codes)\n    - identifying JOIN completion\n    - extracting the welcome nick from 001\n\n    The parser follows the usual IRC split rules:\n    - prefix is optional and begins with ':' at the start of the line\n    - trailing is optional and begins with ' :' and consumes the remainder\n    - params are any remaining space-delimited tokens after command\n    \"\"\"\n    raw = line.rstrip(\"\\r\\n\")\n    prefix: Optional[str] = None\n    trailing: Optional[str] = None\n\n    s = raw\n\n    # Prefix is only present at the beginning of the message.\n    if s.startswith(\":\"):\n        parts = s[1:].split(\" \", 1)\n        prefix = parts[0] if parts else None\n        s = parts[1] if len(parts) > 1 else \"\"\n\n    # Trailing is indicated by \" :\"; it may contain spaces and consumes the\n    # rest.\n    if \" :\" in s:\n        before, after = s.split(\" :\", 1)\n        trailing = after\n        s = before\n\n    s = s.strip()\n    if not s:\n        return IRCMessage(\n            raw=raw, prefix=prefix, command=\"\", params=(), trailing=trailing)\n\n    bits = s.split()\n    command = bits[0]\n    params = tuple(bits[1:]) if len(bits) > 1 else ()\n    return IRCMessage(\n        raw=raw, prefix=prefix, command=command, params=params,\n        trailing=trailing)\n\n\ndef is_ping(msg: IRCMessage) -> bool:\n    \"\"\"True when message is a PING request.\"\"\"\n    return msg.command.upper() == \"PING\"\n\n\ndef ping_payload(msg: IRCMessage) -> str:\n    \"\"\"Extract the payload to use when responding to a PING.\"\"\"\n    if msg.trailing is not None:\n        return msg.trailing\n    return msg.params[0] if msg.params else \"\"\n\n\ndef extract_welcome_nick(msg: IRCMessage) -> Optional[str]:\n    \"\"\"Extract the nickname from the numeric 001 (welcome) message.\"\"\"\n    if msg.numeric != 1:\n        return None\n    if msg.params:\n        return msg.params[0]\n    return None\n\n\ndef normalise_channel(name: str) -> str:\n    \"\"\"Normalise a channel name to include '#'.\"\"\"\n    name = name.strip()\n    if not name:\n        return name\n    return name if name.startswith(\"#\") else f\"#{name}\"\n"
  },
  {
    "path": "apprise/plugins/irc/state.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"IRC State Machine.\n\nThe client reads IRC lines, parses them into :class:`IRCMessage`, and feeds\nthem into :meth:`IRCStateMachine.on_message`. The return value is a list of\n:class:`IRCAction` objects that describe what the client should do next.\n\nResponsibilities:\n- Client: I/O (socket), PING/PONG, retries/backoff, timeouts.\n- State machine: registration/join progress, mapping known numeric errors\n  to a terminal failure, tracking accepted nick, tracking joined channels.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import field\nfrom enum import Enum, auto\nfrom typing import Optional\n\nfrom ...compat import dataclass_compat as dataclass\nfrom .protocol import IRCMessage, extract_welcome_nick\n\n\nclass IRCState(Enum):\n    \"\"\"High-level connection state.\"\"\"\n\n    DISCONNECTED = auto()\n    REGISTERING = auto()\n    READY = auto()\n    JOINING = auto()\n    QUITTING = auto()\n    ERROR = auto()\n\n\nclass IRCActionKind(Enum):\n    \"\"\"Action returned by the state machine.\"\"\"\n\n    SEND = auto()\n    FAIL = auto()\n    NOOP = auto()\n\n\n@dataclass(frozen=True, slots=True)\nclass IRCAction:\n    \"\"\"Represents the next step for the client.\"\"\"\n\n    kind: IRCActionKind\n    line: Optional[str] = None\n    reason: Optional[str] = None\n\n\n@dataclass(slots=True)\nclass IRCContext:\n    \"\"\"Mutable context shared between client and state machine.\"\"\"\n\n    desired_nick: str\n    accepted_nick: str\n    fullname: str\n    password: Optional[str] = None\n    registered: bool = False\n    motd_done: bool = False\n    joined: set[str] = field(default_factory=set)\n    last_error: Optional[str] = None\n\n\ndef _err(msg: IRCMessage) -> str:\n    \"\"\"Build a human readable error message from an IRC message.\"\"\"\n    if msg.trailing:\n        return msg.trailing\n    return \" \".join(msg.params) if msg.params else \"IRC error\"\n\n\nREGISTER_ERRORS = {\n    464: \"Password incorrect\",\n    465: \"Banned from server\",\n    468: \"Only registered users allowed\",\n}\n\nJOIN_ERRORS = {\n    403: \"No such channel\",\n    471: \"Channel is full\",\n    473: \"Invite only channel\",\n    474: \"Banned from channel\",\n    475: \"Bad channel key\",\n    476: \"Bad channel mask\",\n    477: \"Need to be registered\",\n    489: \"Cannot join channel\",\n}\n\n\nclass IRCStateMachine:\n    \"\"\"State machine driven by inbound IRC messages.\"\"\"\n\n    def __init__(self, ctx: IRCContext) -> None:\n        self.ctx = ctx\n        self.state: IRCState = IRCState.DISCONNECTED\n\n    def start_registration(self) -> list[IRCAction]:\n        \"\"\"Begin registration by emitting PASS/NICK/USER as required.\"\"\"\n        self.state = IRCState.REGISTERING\n        out: list[IRCAction] = []\n        if self.ctx.password:\n            out.append(IRCAction(\n                IRCActionKind.SEND, line=f\"PASS {self.ctx.password}\"))\n        out.append(IRCAction(\n            IRCActionKind.SEND, line=f\"NICK {self.ctx.desired_nick}\"))\n        out.append(\n            IRCAction(\n                IRCActionKind.SEND,\n                line=f\"USER {self.ctx.desired_nick} 0 * :{self.ctx.fullname}\",\n            ),\n        )\n        return out\n\n    def on_message(self, msg: IRCMessage) -> list[IRCAction]:\n        \"\"\"Process an inbound IRC message and emit next actions.\"\"\"\n        if self.state in (IRCState.ERROR, IRCState.QUITTING):\n            return []\n\n        actions: list[IRCAction] = []\n        n = msg.numeric\n\n        if self.state == IRCState.REGISTERING:\n            if n in REGISTER_ERRORS:\n                self.ctx.last_error = f\"{REGISTER_ERRORS[n]}: {_err(msg)}\"\n                self.state = IRCState.ERROR\n                return [IRCAction(\n                    IRCActionKind.FAIL, reason=self.ctx.last_error)]\n\n            if n in (432, 433):\n                actions.append(IRCAction(\n                    IRCActionKind.SEND, line=f\"NICK {self.ctx.desired_nick}\"))\n                return actions\n\n            if n == 1:\n                nick = extract_welcome_nick(msg)\n                if nick:\n                    self.ctx.accepted_nick = nick\n                self.ctx.registered = True\n                self.state = IRCState.READY\n                return actions\n\n            if n in (376, 422):\n                self.ctx.motd_done = True\n                if self.ctx.registered:\n                    self.state = IRCState.READY\n                return actions\n\n        if self.state == IRCState.JOINING:\n            if n in JOIN_ERRORS:\n                self.ctx.last_error = f\"{JOIN_ERRORS[n]}: {_err(msg)}\"\n                self.state = IRCState.ERROR\n                return [IRCAction(\n                    IRCActionKind.FAIL, reason=self.ctx.last_error)]\n\n            if n == 443 and len(msg.params) >= 2:\n                chan = msg.params[1]\n                self.ctx.joined.add(chan)\n                self.state = IRCState.READY\n                return actions\n\n            if n == 366 and len(msg.params) >= 2:\n                chan = msg.params[1]\n                self.ctx.joined.add(chan)\n                self.state = IRCState.READY\n                return actions\n\n            if msg.command.upper() == \"JOIN\":\n                chan = msg.trailing or (msg.params[0] if msg.params else \"\")\n                if chan:\n                    self.ctx.joined.add(chan)\n                    self.state = IRCState.READY\n                return actions\n\n        return actions\n\n    def request_join(\n            self, channel: str, key: Optional[str] = None) -> list[IRCAction]:\n        \"\"\"Request a channel join and enter JOINING state.\"\"\"\n        self.state = IRCState.JOINING\n\n        if key:\n            return [IRCAction(\n                IRCActionKind.SEND, line=f\"JOIN {channel} {key}\")]\n        return [IRCAction(IRCActionKind.SEND, line=f\"JOIN {channel}\")]\n\n    def request_quit(self, message: str) -> list[IRCAction]:\n        \"\"\"Request a quit and enter QUITTING state.\"\"\"\n        self.state = IRCState.QUITTING\n        return [IRCAction(IRCActionKind.SEND, line=f\"QUIT :{message}\")]\n"
  },
  {
    "path": "apprise/plugins/irc/templates.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"IRC templates used to auto-apply defaults based on known networks.\n\nThis mirrors the email template strategy, but is intentionally minimal.\nTemplates only apply defaults when the user has not explicitly specified the\nvalue in their URL.\n\nEach entry is a tuple of:\n  (label, compiled_regex, defaults)\n\nSupported defaults:\n  - secure: bool\n  - port: int\n  - mode: str  (server|nickserv|none)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport re\n\nfrom .protocol import IRCAuthMode\n\nIRC_TEMPLATES = (\n    # SynIRC: NickServ common, TLS default 6697\n    (\n        \"SynIRC\",\n        re.compile(r\"^.+\\.synirc\\.net$\", re.I),\n        {\n            \"secure\": True,\n            \"port\": 6697,\n            \"mode\": IRCAuthMode.NICKSERV,\n        },\n    ),\n\n    # Libera.Chat: TLS default 6697, NickServ common (SASL also common but\n    # not implemented here)\n    (\n        \"Libera.Chat\",\n        re.compile(r\"^.+\\.libera\\.chat$\", re.I),\n        {\n            \"secure\": True,\n            \"port\": 6697,\n            \"mode\": IRCAuthMode.NICKSERV,\n        },\n    ),\n\n    # EFnet: traditionally plain 6667, auth varies widely\n    (\n        \"EFnet\",\n        re.compile(r\"^.+\\.efnet\\.org$\", re.I),\n        {\n            \"secure\": False,\n            \"port\": 6667,\n            \"mode\": IRCAuthMode.SERVER,\n        },\n    ),\n)\n"
  },
  {
    "path": "apprise/plugins/jellyfin.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"Jellyfin Notification Plugin.\n\nJellyfin is a fork of Emby and kept compatible endpoints for the on-screen\nmessage / remote control features used by Apprise.\n\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom . import emby\n\n\nclass NotifyJellyfin(emby.NotifyEmby):\n    \"\"\"Send notifications to Jellyfin (Emby-compatible) servers.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Jellyfin\"\n\n    # The services URL\n    service_url = \"https://jellyfin.org/\"\n\n    # The default protocol\n    protocol = \"jellyfin\"\n\n    # The default secure protocol\n    secure_protocol = \"jellyfins\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/jellyfin/\"\n"
  },
  {
    "path": "apprise/plugins/join.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Join URL: http://joaoapps.com/join/\n# To use this plugin, you need to first access (make sure your browser allows\n#  popups): https://joinjoaomgcd.appspot.com/\n#\n# To register you just need to allow it to connect to your Google Profile but\n# the good news is it doesn't ask for anything too personal.\n#\n# You can download the app for your phone here:\n#   https://play.google.com/store/apps/details?id=com.joaomgcd.join\n\nimport re\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nJOIN_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n# Used to detect a device\nIS_DEVICE_RE = re.compile(r\"^[a-z0-9]{32}$\", re.I)\n\n# Used to detect a device\nIS_GROUP_RE = re.compile(\n    r\"(group\\.)?(?P<name>(all|android|chrome|windows10|phone|tablet|pc))\",\n    re.IGNORECASE,\n)\n\n# Image Support (72x72)\nJOIN_IMAGE_XY = NotifyImageSize.XY_72\n\n\n# Priorities\nclass JoinPriority:\n    LOW = -2\n    MODERATE = -1\n    NORMAL = 0\n    HIGH = 1\n    EMERGENCY = 2\n\n\nJOIN_PRIORITIES = {\n    # Note: This also acts as a reverse lookup mapping\n    JoinPriority.LOW: \"low\",\n    JoinPriority.MODERATE: \"moderate\",\n    JoinPriority.NORMAL: \"normal\",\n    JoinPriority.HIGH: \"high\",\n    JoinPriority.EMERGENCY: \"emergency\",\n}\n\nJOIN_PRIORITY_MAP = {\n    # Maps against string 'low'\n    \"l\": JoinPriority.LOW,\n    # Maps against string 'moderate'\n    \"m\": JoinPriority.MODERATE,\n    # Maps against string 'normal'\n    \"n\": JoinPriority.NORMAL,\n    # Maps against string 'high'\n    \"h\": JoinPriority.HIGH,\n    # Maps against string 'emergency'\n    \"e\": JoinPriority.EMERGENCY,\n    # Entries to additionally support (so more like Join's API)\n    \"-2\": JoinPriority.LOW,\n    \"-1\": JoinPriority.MODERATE,\n    \"0\": JoinPriority.NORMAL,\n    \"1\": JoinPriority.HIGH,\n    \"2\": JoinPriority.EMERGENCY,\n}\n\n\nclass NotifyJoin(NotifyBase):\n    \"\"\"A wrapper for Join Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Join\"\n\n    # The services URL\n    service_url = \"https://joaoapps.com/join/\"\n\n    # The default protocol\n    secure_protocol = \"join\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/join/\"\n\n    # Join uses the http protocol with JSON requests\n    notify_url = (\n        \"https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush\"\n    )\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1000\n\n    # The default group to use if none is specified\n    default_join_group = \"group.all\"\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z0-9]{32}$\", \"i\"),\n                \"private\": True,\n                \"required\": True,\n            },\n            \"device\": {\n                \"name\": _(\"Device ID\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z0-9]{32}$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"device_name\": {\n                \"name\": _(\"Device Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"group\": {\n                \"name\": _(\"Group\"),\n                \"type\": \"choice:string\",\n                \"values\": (\n                    \"all\",\n                    \"android\",\n                    \"chrome\",\n                    \"windows10\",\n                    \"phone\",\n                    \"tablet\",\n                    \"pc\",\n                ),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": JOIN_PRIORITIES,\n                \"default\": JoinPriority.NORMAL,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self, apikey, targets=None, include_image=True, priority=None, **kwargs\n    ):\n        \"\"\"Initialize Join Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Track whether or not we want to send an image with our notification\n        # or not.\n        self.include_image = include_image\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Join API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The Priority of the message\n        self.priority = int(\n            NotifyJoin.template_args[\"priority\"][\"default\"]\n            if priority is None\n            else next(\n                (\n                    v\n                    for k, v in JOIN_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyJoin.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        # Prepare a list of targets to store entries into\n        self.targets = []\n\n        # Prepare a parsed list of targets\n        targets = parse_list(targets)\n        if len(targets) == 0:\n            # Default to everyone if our list was empty\n            self.targets.append(self.default_join_group)\n            return\n\n        # If we reach here we have some targets to parse\n        while len(targets):\n            # Parse our targets\n            target = targets.pop(0)\n            group_re = IS_GROUP_RE.match(target)\n            if group_re:\n                self.targets.append(\n                    \"group.{}\".format(group_re.group(\"name\").lower())\n                )\n                continue\n\n            self.targets.append(target)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Join Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Capture a list of our targets to notify\n        targets = list(self.targets)\n\n        while len(targets):\n            # Pop the first element off of our list\n            target = targets.pop(0)\n\n            url_args = {\n                \"apikey\": self.apikey,\n                \"priority\": str(self.priority),\n                \"title\": title,\n                \"text\": body,\n            }\n\n            if IS_GROUP_RE.match(target) or IS_DEVICE_RE.match(target):\n                url_args[\"deviceId\"] = target\n\n            else:\n                # Support Device Names\n                url_args[\"deviceNames\"] = target\n\n            # prepare our image for display if configured to do so\n            image_url = (\n                None if not self.include_image else self.image_url(notify_type)\n            )\n\n            if image_url:\n                url_args[\"icon\"] = image_url\n\n            # prepare payload\n            payload = {}\n\n            # Prepare the URL\n            url = f\"{self.notify_url}?{NotifyJoin.urlencode(url_args)}\"\n\n            self.logger.debug(\n                \"Join POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Join Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    url,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyJoin.http_response_code_lookup(\n                        r.status_code, JOIN_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Join notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Join notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending Join:{target} \"\n                    \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n        # Define any URL parameters\n        params = {\n            \"priority\": (\n                JOIN_PRIORITIES[self.template_args[\"priority\"][\"default\"]]\n                if self.priority not in JOIN_PRIORITIES\n                else JOIN_PRIORITIES[self.priority]\n            ),\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{apikey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyJoin.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyJoin.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Our API Key is the hostname if no user is specified\n        results[\"apikey\"] = (\n            results[\"user\"] if results[\"user\"] else results[\"host\"]\n        )\n\n        # Unquote our API Key\n        results[\"apikey\"] = NotifyJoin.unquote(results[\"apikey\"])\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyJoin.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        # Our Devices\n        results[\"targets\"] = []\n        if results[\"user\"]:\n            # If a user was defined, then the hostname is actually a target\n            # too\n            results[\"targets\"].append(NotifyJoin.unquote(results[\"host\"]))\n\n        # Now fetch the remaining tokens\n        results[\"targets\"].extend(NotifyJoin.split_path(results[\"fullpath\"]))\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyJoin.parse_list(results[\"qsd\"][\"to\"])\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/kavenegar.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this service you will need a Kavenegar account from their website\n# at https://kavenegar.com/\n#\n# After you've established your account you can get your API Key from your\n# account profile: https://panel.kavenegar.com/client/setting/account\n#\n# This provider does not accept +1 (for example) as a country code. You need\n# to specify 001 instead.\n#\nfrom json import loads\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\n# Based on https://kavenegar.com/rest.html\nKAVENEGAR_HTTP_ERROR_MAP = {\n    200: \"The request was approved\",\n    400: \"Parameters are incomplete\",\n    401: \"Account has been disabled\",\n    402: \"The operation failed\",\n    403: \"The API Key is invalid\",\n    404: \"The method is unknown\",\n    405: \"The GET/POST request is wrong\",\n    406: \"Invalid mandatory parameters sent\",\n    407: \"You canot access the information you want\",\n    409: \"The server is unable to response\",\n    411: \"The recipient is invalid\",\n    412: \"The sender is invalid\",\n    413: \"Message empty or message length exceeded\",\n    414: \"The number of recipients is more than 200\",\n    415: \"The start index is larger then the total\",\n    416: \"The source IP of the service does not match the settings\",\n    417: (\n        \"The submission date is incorrect, \"\n        \"either expired or not in the correct format\"\n    ),\n    418: \"Your account credit is insufficient\",\n    422: \"Data cannot be processed due to invalid characters\",\n    501: \"SMS can only be sent to the account holder number\",\n}\n\n\nclass NotifyKavenegar(NotifyBase):\n    \"\"\"A wrapper for Kavenegar Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Kavenegar\"\n\n    # The services URL\n    service_url = \"https://kavenegar.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"kavenegar\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/kavenegar/\"\n\n    # Kavenegar single notification URL\n    notify_url = \"http://api.kavenegar.com/v1/{apikey}/sms/send.json\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}/{targets}\",\n        \"{schema}://{source}@{apikey}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"source\": {\n                \"name\": _(\"Source Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"from\": {\n                \"alias_of\": \"source\",\n            },\n        },\n    )\n\n    def __init__(self, apikey, source=None, targets=None, **kwargs):\n        \"\"\"Initialize Kavenegar Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Kavenegar API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.source = None\n        if source is not None:\n            result = is_phone_no(source)\n            if not result:\n                msg = f\"The Kavenegar source specified ({source}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            # Store our source\n            self.source = result[\"full\"]\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Sends SMS Message.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no Kavenegar targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n        }\n\n        # Our URL\n        url = self.notify_url.format(apikey=self.apikey)\n\n        # use the list directly\n        targets = list(self.targets)\n\n        while len(targets):\n            # Get our target(s) to notify\n            target = targets.pop(0)\n\n            # Prepare our payload\n            payload = {\n                \"receptor\": target,\n                \"message\": body,\n            }\n\n            if self.source:\n                # Only set source if specified\n                payload[\"sender\"] = self.source\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Kavenegar POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Kavenegar Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    url,\n                    params=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code not in (\n                    requests.codes.created,\n                    requests.codes.ok,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code, KAVENEGAR_HTTP_ERROR_MAP\n                    )\n\n                    try:\n                        # Update our status response if we can\n                        json_response = loads(r.content)\n                        status_str = json_response.get(\"message\", status_str)\n\n                    except (AttributeError, TypeError, ValueError):\n                        # ValueError = r.content is Unparsable\n                        # TypeError = r.content is None\n                        # AttributeError = r is None\n\n                        # We could not parse JSON response.\n                        # We will just use the status we already have.\n                        pass\n\n                    self.logger.warning(\n                        \"Failed to send Kavenegar SMS notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                # If we reach here; the message was sent\n                self.logger.info(\n                    f\"Sent Kavenegar SMS notification to {target}.\"\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Kavenegar:{} \".format(\n                        \", \".join(self.targets)\n                    )\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.source, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{source}{apikey}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            source=\"\" if not self.source else f\"{self.source}@\",\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyKavenegar.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyKavenegar.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Store the source if specified\n        if results.get(\"user\", None):\n            results[\"source\"] = results[\"user\"]\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyKavenegar.split_path(results[\"fullpath\"])\n\n        # The hostname is our authentication key\n        results[\"apikey\"] = NotifyKavenegar.unquote(results[\"host\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyKavenegar.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyKavenegar.unquote(results[\"qsd\"][\"from\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/kodi.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n\nclass NotifyXBMC(NotifyBase):\n    \"\"\"A wrapper for XBMC/KODI Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Kodi/XBMC\"\n\n    # The services URL\n    service_url = \"http://kodi.tv/\"\n\n    xbmc_protocol = \"xbmc\"\n    xbmc_secure_protocol = \"xbmcs\"\n    kodi_protocol = \"kodi\"\n    kodi_secure_protocol = \"kodis\"\n\n    # The default protocols\n    protocol = (kodi_protocol, xbmc_protocol)\n\n    # The default secure protocols\n    secure_protocol = (kodi_secure_protocol, xbmc_secure_protocol)\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/kodi/\"\n\n    # Disable throttle rate for XBMC/KODI requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Limit results to just the first 2 line otherwise there is just to much\n    # content to display\n    body_max_line_count = 2\n\n    # XBMC uses the http protocol with JSON requests\n    xbmc_default_port = 8080\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # XBMC default protocol version (v2)\n    xbmc_remote_protocol = 2\n\n    # KODI default protocol version (v6)\n    kodi_remote_protocol = 6\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n    )\n\n    # Define our tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"duration\": {\n                \"name\": _(\"Duration\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"default\": 12,\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    def __init__(self, include_image=True, duration=None, **kwargs):\n        \"\"\"Initialize XBMC/KODI Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Number of seconds to display notification for\n        self.duration = (\n            self.template_args[\"duration\"][\"default\"]\n            if not (\n                isinstance(duration, int)\n                and self.template_args[\"duration\"][\"min\"] > 0\n            )\n            else duration\n        )\n\n        # Build our schema\n        self.schema = \"https\" if self.secure else \"http\"\n\n        # Prepare the default header\n        self.headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Default protocol\n        self.protocol = kwargs.get(\"protocol\", self.xbmc_remote_protocol)\n\n        # Track whether or not we want to send an image with our notification\n        # or not.\n        self.include_image = include_image\n\n    def _payload_60(self, title, body, notify_type, **kwargs):\n        \"\"\"Builds payload for KODI API v6.0.\n\n        Returns (headers, payload)\n        \"\"\"\n\n        # prepare JSON Object\n        payload = {\n            \"jsonrpc\": \"2.0\",\n            \"method\": \"GUI.ShowNotification\",\n            \"params\": {\n                \"title\": title,\n                \"message\": body,\n                # displaytime is defined in microseconds so we need to just\n                # do some simple math\n                \"displaytime\": int(self.duration * 1000),\n            },\n            \"id\": 1,\n        }\n\n        # Acquire our image url if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        if image_url:\n            payload[\"params\"][\"image\"] = image_url\n            if notify_type is NotifyType.FAILURE:\n                payload[\"type\"] = \"error\"\n\n            elif notify_type is NotifyType.WARNING:\n                payload[\"type\"] = \"warning\"\n\n            else:\n                payload[\"type\"] = \"info\"\n\n        return (self.headers, dumps(payload))\n\n    def _payload_20(self, title, body, notify_type, **kwargs):\n        \"\"\"Builds payload for XBMC API v2.0.\n\n        Returns (headers, payload)\n        \"\"\"\n\n        # prepare JSON Object\n        payload = {\n            \"jsonrpc\": \"2.0\",\n            \"method\": \"GUI.ShowNotification\",\n            \"params\": {\n                \"title\": title,\n                \"message\": body,\n                # displaytime is defined in microseconds so we need to just\n                # do some simple math\n                \"displaytime\": int(self.duration * 1000),\n            },\n            \"id\": 1,\n        }\n\n        # Include our logo if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        if image_url:\n            payload[\"params\"][\"image\"] = image_url\n\n        return (self.headers, dumps(payload))\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform XBMC/KODI Notification.\"\"\"\n\n        if self.protocol == self.xbmc_remote_protocol:\n            # XBMC v2.0\n            (headers, payload) = self._payload_20(\n                title, body, notify_type, **kwargs\n            )\n\n        else:\n            # KODI v6.0\n            (headers, payload) = self._payload_60(\n                title, body, notify_type, **kwargs\n            )\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        url = f\"{self.schema}://{self.host}\"\n        if self.port:\n            url += f\":{self.port}\"\n\n        url += \"/jsonrpc\"\n\n        self.logger.debug(\n            f\"XBMC/KODI POST URL: {url} \"\n            f\"(cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"XBMC/KODI Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=payload,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyXBMC.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send XBMC/KODI notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent XBMC/KODI notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending XBMC/KODI notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        default_schema = (\n            self.xbmc_protocol\n            if (self.protocol <= self.xbmc_remote_protocol)\n            else self.kodi_protocol\n        )\n        if self.secure:\n            # Append 's' to schema\n            default_schema += \"s\"\n\n        port = (\n            self.port\n            if self.port\n            else (443 if self.secure else self.xbmc_default_port)\n        )\n        return (\n            default_schema,\n            self.user,\n            self.password,\n            self.host,\n            port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"duration\": str(self.duration),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyXBMC.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyXBMC.quote(self.user, safe=\"\"),\n            )\n\n        default_schema = (\n            self.xbmc_protocol\n            if (self.protocol <= self.xbmc_remote_protocol)\n            else self.kodi_protocol\n        )\n        default_port = 443 if self.secure else self.xbmc_default_port\n        if self.secure:\n            # Append 's' to schema\n            default_schema += \"s\"\n\n        return \"{schema}://{auth}{hostname}{port}/?{params}\".format(\n            schema=default_schema,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if not self.port or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            params=NotifyXBMC.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early\n            return results\n\n        # We want to set our protocol depending on whether we're using XBMC\n        # or KODI\n        if results.get(\"schema\", \"\").startswith(\"xbmc\"):\n            # XBMC Support\n            results[\"protocol\"] = NotifyXBMC.xbmc_remote_protocol\n\n            # Assign Default XBMC Port\n            if not results[\"port\"]:\n                results[\"port\"] = NotifyXBMC.xbmc_default_port\n\n        else:\n            # KODI Support\n            results[\"protocol\"] = NotifyXBMC.kodi_remote_protocol\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # Set duration if data is valid\n        with contextlib.suppress(TypeError, ValueError):\n            results[\"duration\"] = abs(int(results[\"qsd\"].get(\"duration\")))\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/kumulos.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you must have a Kumulos account set up. Add a client\n# and link it with your phone using the phone app (using your Companion App\n# option in the profile menu area):\n#    Android: https://play.google.com/store/apps/\\\n#                     details?id=com.kumulos.companion\n#    iOS: https://apps.apple.com/us/app/kumulos/id1463947782\n#\n# The API reference used to build this plugin was documented here:\n#  https://docs.kumulos.com/messaging/api/#sending-in-app-messages\n#\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nKUMULOS_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid API and/or Server Key.\",\n    422: \"Unprocessable Entity - The request was unparsable.\",\n    400: \"Bad Request - Targeted users do not exist or have unsubscribed.\",\n}\n\n\nclass NotifyKumulos(NotifyBase):\n    \"\"\"A wrapper for Kumulos Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Kumulos\"\n\n    # The services URL\n    service_url = \"https://kumulos.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"kumulos\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/kumulos/\"\n\n    # Kumulos uses the http protocol with JSON requests\n    notify_url = \"https://messages.kumulos.com/v2/notifications\"\n\n    # The maximum allowable characters allowed in the title per message\n    title_maxlen = 64\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 240\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}/{serverkey}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                # UUID4\n                \"regex\": (\n                    r\"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-\"\n                    r\"[89ab][0-9a-f]{3}-[0-9a-f]{12}$\",\n                    \"i\",\n                ),\n            },\n            \"serverkey\": {\n                \"name\": _(\"Server Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9+]{36}$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, apikey, serverkey, **kwargs):\n        \"\"\"Initialize Kumulos Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Kumulos API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Server Key (associated with project)\n        self.serverkey = validate_regex(\n            serverkey, *self.template_tokens[\"serverkey\"][\"regex\"]\n        )\n        if not self.serverkey:\n            msg = f\"An invalid Kumulos Server Key ({serverkey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Kumulos Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n        }\n\n        # prepare JSON Object\n        payload = {\n            \"target\": {\n                \"broadcast\": True,\n            },\n            \"content\": {\n                \"title\": title,\n                \"message\": body,\n            },\n        }\n\n        # Determine Authentication\n        auth = (self.apikey, self.serverkey)\n\n        self.logger.debug(\n            \"Kumulos POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Kumulos Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=dumps(payload),\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyKumulos.http_response_code_lookup(\n                    r.status_code, KUMULOS_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send Kumulos notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False\n\n            else:\n                self.logger.info(\"Sent Kumulos notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Kumulos notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey, self.serverkey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{apikey}/{serverkey}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            serverkey=self.pprint(self.serverkey, privacy, safe=\"\"),\n            params=NotifyKumulos.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The first token is stored in the hostname\n        results[\"apikey\"] = NotifyKumulos.unquote(results[\"host\"])\n\n        # Now fetch the remaining tokens\n        try:\n            results[\"serverkey\"] = NotifyKumulos.split_path(\n                results[\"fullpath\"]\n            )[0]\n\n        except IndexError:\n            # no token\n            results[\"serverkey\"] = None\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/lametric.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For LaMetric to work, you need to first setup a custom application on their\n# website. it can be done as follows:\n\n# Cloud Mode:\n# - Sign Up and login to the developer webpage https://developer.lametric.com\n#\n# - Create a **Indicator App** if you haven't already done so from here:\n#     https://developer.lametric.com/applications/sources\n#\n#   There is a great official tutorial on how to do this here:\n#     https://lametric-documentation.readthedocs.io/en/latest/\\\n#           guides/first-steps/first-lametric-indicator-app.html\n#\n# - Make sure to set the **Communication Type** to **PUSH**.\n#\n# - You will be able to **Publish** your app once you've finished setting it\n#   up.  This will allow it to be accessible from the internet using the\n#   `cloud` mode of this Apprise Plugin. The **Publish** button shows up\n#   from within the settings of your Lametric App upon clicking on the\n#   **Draft Vx** folder (where `x` is the version - usually a 1)\n#\n# When you've completed, the site would have provided you a **PUSH URL** that\n# looks like this:\n#    https://developer.lametric.com/api/v1/dev/widget/update/\\\n#             com.lametric.{app_id}/{app_ver}\n#\n# You will need to record the `{app_id}` and `{app_ver}` to use the `cloud`\n# mode.\n#\n# The same page should also provide you with an **Access Token**.  It's\n# approximately 86 characters with two equal (`=`) characters at the end of it.\n# This becomes your `{app_token}`. Here is an example of what one might\n# look like:\n#    K2MxWI0NzU0ZmI2NjJlZYTgViMDgDRiN8YjlmZjRmNTc4NDVhJzk0RiNjNh0EyKWW==`\n#\n# The syntax for the cloud mode is:\n# * `lametric://{app_token}@{app_id}/{app_ver}?mode=cloud`\n\n# Device Mode:\n# - Sign Up and login to the developer webpage https://developer.lametric.com\n# - Locate your Device API Key; you can find it here:\n#      https://developer.lametric.com/user/devices\n# - From here you can get your your API Key for the device you plan to notify.\n# - Your devices IP Address can be found in LaMetric Time app at:\n#       Settings -> Wi-Fi -> IP Address\n#\n# The syntax for the device mode is:\n#  * `lametric://{apikey}@{host}`\n\n# A great source for API examples (Device Mode):\n# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\\\n#       /device-notifications.html\n#\n# A great source for API examples (Cloud Mode):\n# - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\\\n#       /lametric-cloud-reference.html\n\n# A great source for the icon reference:\n# - https://developer.lametric.com/icons\n\n\nimport contextlib\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_hostname, is_ipaddr, validate_regex\nfrom .base import NotifyBase\n\n# A URL Parser to detect App ID\nLAMETRIC_APP_ID_DETECTOR_RE = re.compile(\n    r\"(com\\.lametric\\.)?(?P<app_id>[0-9a-z.-]{1,64})\"\n    r\"(/(?P<app_ver>[1-9][0-9]*))?\",\n    re.I,\n)\n\n# Tokens are huge\nLAMETRIC_IS_APP_TOKEN = re.compile(r\"^[a-z0-9]{80,}==$\", re.I)\n\n\nclass LametricMode:\n    \"\"\"Define Lametric Notification Modes.\"\"\"\n\n    # App posts upstream to the developer API on Lametric's website\n    CLOUD = \"cloud\"\n\n    # Device mode posts directly to the device that you identify\n    DEVICE = \"device\"\n\n\nLAMETRIC_MODES = (\n    LametricMode.CLOUD,\n    LametricMode.DEVICE,\n)\n\n\nclass LametricPriority:\n    \"\"\"Priority of the message.\"\"\"\n\n    # info: this priority means that notification will be displayed on the\n    #        same “level” as all other notifications on the device that come\n    #        from apps (for example facebook app). This notification will not\n    #        be shown when screensaver is active. By default message is sent\n    #        with \"info\" priority. This level of notification should be used\n    #        for notifications like news, weather, temperature, etc.\n    INFO = \"info\"\n\n    # warning: notifications with this priority will interrupt ones sent with\n    #           lower priority (“info”). Should be used to notify the user\n    #           about something important but not critical. For example,\n    #           events like “someone is coming home” should use this priority\n    #           when sending notifications from smart home.\n    WARNING = \"warning\"\n\n    # critical: the most important notifications. Interrupts notification\n    #            with priority info or warning and is displayed even if\n    #            screensaver is active. Use with care as these notifications\n    #            can pop in the middle of the night. Must be used only for\n    #            really important notifications like notifications from smoke\n    #            detectors, water leak sensors, etc. Use it for events that\n    #            require human interaction immediately.\n    CRITICAL = \"critical\"\n\n\nLAMETRIC_PRIORITIES = (\n    LametricPriority.INFO,\n    LametricPriority.WARNING,\n    LametricPriority.CRITICAL,\n)\n\n\nclass LametricIconType:\n    \"\"\"Represents the nature of notification.\"\"\"\n\n    # info  - \"i\" icon will be displayed prior to the notification. Means that\n    #         notification contains information, no need to take actions on it.\n    INFO = \"info\"\n\n    # alert: \"!!!\" icon will be displayed prior to the notification. Use it\n    #         when you want the user to pay attention to that notification as\n    #         it indicates that something bad happened and user must take\n    #         immediate action.\n    ALERT = \"alert\"\n\n    # none: no notification icon will be shown.\n    NONE = \"none\"\n\n\nLAMETRIC_ICON_TYPES = (\n    LametricIconType.INFO,\n    LametricIconType.ALERT,\n    LametricIconType.NONE,\n)\n\n\nclass LametricSoundCategory:\n    \"\"\"Define Sound Categories.\"\"\"\n\n    NOTIFICATIONS = \"notifications\"\n    ALARMS = \"alarms\"\n\n\nclass LametricSound:\n    \"\"\"There are 2 categories of sounds, to make things simple we just lump\n    them all togther in one class object.\n\n    Syntax is (Category, (AlarmID, Alias1, Alias2, ...))\n    \"\"\"\n\n    # Alarm Category Sounds\n    ALARM01 = (LametricSoundCategory.ALARMS, (\"alarm1\", \"a1\", \"a01\"))\n    ALARM02 = (LametricSoundCategory.ALARMS, (\"alarm2\", \"a2\", \"a02\"))\n    ALARM03 = (LametricSoundCategory.ALARMS, (\"alarm3\", \"a3\", \"a03\"))\n    ALARM04 = (LametricSoundCategory.ALARMS, (\"alarm4\", \"a4\", \"a04\"))\n    ALARM05 = (LametricSoundCategory.ALARMS, (\"alarm5\", \"a5\", \"a05\"))\n    ALARM06 = (LametricSoundCategory.ALARMS, (\"alarm6\", \"a6\", \"a06\"))\n    ALARM07 = (LametricSoundCategory.ALARMS, (\"alarm7\", \"a7\", \"a07\"))\n    ALARM08 = (LametricSoundCategory.ALARMS, (\"alarm8\", \"a8\", \"a08\"))\n    ALARM09 = (LametricSoundCategory.ALARMS, (\"alarm9\", \"a9\", \"a09\"))\n    ALARM10 = (LametricSoundCategory.ALARMS, (\"alarm10\", \"a10\"))\n    ALARM11 = (LametricSoundCategory.ALARMS, (\"alarm11\", \"a11\"))\n    ALARM12 = (LametricSoundCategory.ALARMS, (\"alarm12\", \"a12\"))\n    ALARM13 = (LametricSoundCategory.ALARMS, (\"alarm13\", \"a13\"))\n\n    # Notification Category Sounds\n    BICYCLE = (LametricSoundCategory.NOTIFICATIONS, (\"bicycle\", \"bike\"))\n    CAR = (LametricSoundCategory.NOTIFICATIONS, (\"car\",))\n    CASH = (LametricSoundCategory.NOTIFICATIONS, (\"cash\",))\n    CAT = (LametricSoundCategory.NOTIFICATIONS, (\"cat\",))\n    DOG01 = (LametricSoundCategory.NOTIFICATIONS, (\"dog\", \"dog1\", \"dog01\"))\n    DOG02 = (LametricSoundCategory.NOTIFICATIONS, (\"dog2\", \"dog02\"))\n    ENERGY = (LametricSoundCategory.NOTIFICATIONS, (\"energy\",))\n    KNOCK = (LametricSoundCategory.NOTIFICATIONS, (\"knock-knock\", \"knock\"))\n    EMAIL = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"letter_email\", \"letter\", \"email\"),\n    )\n    LOSE01 = (LametricSoundCategory.NOTIFICATIONS, (\"lose1\", \"lose01\", \"lose\"))\n    LOSE02 = (LametricSoundCategory.NOTIFICATIONS, (\"lose2\", \"lose02\"))\n    NEGATIVE01 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"negative1\", \"negative01\", \"neg01\", \"neg1\", \"-\"),\n    )\n    NEGATIVE02 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"negative2\", \"negative02\", \"neg02\", \"neg2\", \"--\"),\n    )\n    NEGATIVE03 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"negative3\", \"negative03\", \"neg03\", \"neg3\", \"---\"),\n    )\n    NEGATIVE04 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"negative4\", \"negative04\", \"neg04\", \"neg4\", \"----\"),\n    )\n    NEGATIVE05 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"negative5\", \"negative05\", \"neg05\", \"neg5\", \"-----\"),\n    )\n    NOTIFICATION01 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"notification\", \"notification1\", \"notification01\", \"not01\", \"not1\"),\n    )\n    NOTIFICATION02 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"notification2\", \"notification02\", \"not02\", \"not2\"),\n    )\n    NOTIFICATION03 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"notification3\", \"notification03\", \"not03\", \"not3\"),\n    )\n    NOTIFICATION04 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"notification4\", \"notification04\", \"not04\", \"not4\"),\n    )\n    OPEN_DOOR = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"open_door\", \"open\", \"door\"),\n    )\n    POSITIVE01 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"positive1\", \"positive01\", \"pos01\", \"p1\", \"+\"),\n    )\n    POSITIVE02 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"positive2\", \"positive02\", \"pos02\", \"p2\", \"++\"),\n    )\n    POSITIVE03 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"positive3\", \"positive03\", \"pos03\", \"p3\", \"+++\"),\n    )\n    POSITIVE04 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"positive4\", \"positive04\", \"pos04\", \"p4\", \"++++\"),\n    )\n    POSITIVE05 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"positive5\", \"positive05\", \"pos05\", \"p5\", \"+++++\"),\n    )\n    POSITIVE06 = (\n        LametricSoundCategory.NOTIFICATIONS,\n        (\"positive6\", \"positive06\", \"pos06\", \"p6\", \"++++++\"),\n    )\n    STATISTIC = (LametricSoundCategory.NOTIFICATIONS, (\"statistic\", \"stat\"))\n    THUNDER = (LametricSoundCategory.NOTIFICATIONS, \"thunder\")\n    WATER01 = (LametricSoundCategory.NOTIFICATIONS, (\"water1\", \"water01\"))\n    WATER02 = (LametricSoundCategory.NOTIFICATIONS, (\"water2\", \"water02\"))\n    WIN01 = (LametricSoundCategory.NOTIFICATIONS, (\"win\", \"win01\", \"win1\"))\n    WIN02 = (LametricSoundCategory.NOTIFICATIONS, (\"win2\", \"win02\"))\n    WIND = (LametricSoundCategory.NOTIFICATIONS, (\"wind\",))\n    WIND_SHORT = (LametricSoundCategory.NOTIFICATIONS, (\"wind_short\",))\n\n\n# A listing of all the sounds; the order DOES matter, content is read from\n# top down and then right to left (over aliases). Longer similar sounding\n# elements should be placed higher in the list over others. for example\n# ALARM10 should come before ALARM01 (because ALARM01 can match on 'alarm1'\n# which is very close to 'alarm10'\nLAMETRIC_SOUNDS = (\n    # Alarm Category Entries\n    LametricSound.ALARM13,\n    LametricSound.ALARM12,\n    LametricSound.ALARM11,\n    LametricSound.ALARM10,\n    LametricSound.ALARM09,\n    LametricSound.ALARM08,\n    LametricSound.ALARM07,\n    LametricSound.ALARM06,\n    LametricSound.ALARM05,\n    LametricSound.ALARM04,\n    LametricSound.ALARM03,\n    LametricSound.ALARM02,\n    LametricSound.ALARM01,\n    # Notification Category Entries\n    LametricSound.BICYCLE,\n    LametricSound.CAR,\n    LametricSound.CASH,\n    LametricSound.CAT,\n    LametricSound.DOG02,\n    LametricSound.DOG01,\n    LametricSound.ENERGY,\n    LametricSound.KNOCK,\n    LametricSound.EMAIL,\n    LametricSound.LOSE02,\n    LametricSound.LOSE01,\n    LametricSound.NEGATIVE01,\n    LametricSound.NEGATIVE02,\n    LametricSound.NEGATIVE03,\n    LametricSound.NEGATIVE04,\n    LametricSound.NEGATIVE05,\n    LametricSound.NOTIFICATION04,\n    LametricSound.NOTIFICATION03,\n    LametricSound.NOTIFICATION02,\n    LametricSound.NOTIFICATION01,\n    LametricSound.OPEN_DOOR,\n    LametricSound.POSITIVE01,\n    LametricSound.POSITIVE02,\n    LametricSound.POSITIVE03,\n    LametricSound.POSITIVE04,\n    LametricSound.POSITIVE05,\n    LametricSound.POSITIVE01,\n    LametricSound.STATISTIC,\n    LametricSound.THUNDER,\n    LametricSound.WATER02,\n    LametricSound.WATER01,\n    LametricSound.WIND,\n    LametricSound.WIND_SHORT,\n    LametricSound.WIN01,\n    LametricSound.WIN02,\n)\n\n\nclass NotifyLametric(NotifyBase):\n    \"\"\"A wrapper for LaMetric Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"LaMetric\"\n\n    # The services URL\n    service_url = \"https://lametric.com\"\n\n    # The default protocol\n    protocol = \"lametric\"\n\n    # The default secure protocol\n    secure_protocol = \"lametrics\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/lametric/\"\n\n    # Lametric does have titles when creating a message\n    title_maxlen = 0\n\n    # URL used for notifying Lametric App's created in the Dev Portal\n    cloud_notify_url = (\n        \"https://developer.lametric.com/api/v1\"\n        \"/dev/widget/update/com.lametric.{app_id}/{app_ver}\"\n    )\n\n    # URL used for local notifications directly to the device\n    device_notify_url = \"{schema}://{host}{port}/api/v2/device/notifications\"\n\n    # The Device User ID\n    default_device_user = \"dev\"\n\n    # Track all icon mappings back to Apprise Icon NotifyType's\n    # See: https://developer.lametric.com/icons\n    # Icon ID looks like <prefix>XXX, where <prefix> is:\n    #   - \"i\" (for static icon)\n    #   - \"a\" (for animation)\n    #   - XXX - is the number of the icon and can be found at:\n    #            https://developer.lametric.com/icons\n    lametric_icon_id_mapping = {\n        # 620/Info\n        NotifyType.INFO: \"i620\",\n        # 9182/info_good\n        NotifyType.SUCCESS: \"i9182\",\n        # 9183/info_caution\n        NotifyType.WARNING: \"i9183\",\n        # 9184/info_error\n        NotifyType.FAILURE: \"i9184\",\n    }\n\n    # Define object templates\n    templates = (\n        # Cloud (App) Mode\n        \"{schema}://{app_token}@{app_id}\",\n        \"{schema}://{app_token}@{app_id}/{app_ver}\",\n        # Device Mode\n        \"{schema}://{apikey}@{host}\",\n        \"{schema}://{user}:{apikey}@{host}\",\n        \"{schema}://{apikey}@{host}:{port}\",\n        \"{schema}://{user}:{apikey}@{host}:{port}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            # Used for Local Device mode\n            \"apikey\": {\n                \"name\": _(\"Device API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            # Used for Cloud mode\n            \"app_id\": {\n                \"name\": _(\"App ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            # Used for Cloud mode\n            \"app_ver\": {\n                \"name\": _(\"App Version\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[1-9][0-9]*$\", \"\"),\n                \"default\": \"1\",\n            },\n            # Used for Cloud mode\n            \"app_token\": {\n                \"name\": _(\"App Access Token\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[A-Z0-9]{80,}==$\", \"i\"),\n            },\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n                \"default\": 8080,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"apikey\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"app_id\": {\n                \"alias_of\": \"app_id\",\n            },\n            \"app_ver\": {\n                \"alias_of\": \"app_ver\",\n            },\n            \"app_token\": {\n                \"alias_of\": \"app_token\",\n            },\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:string\",\n                \"values\": LAMETRIC_PRIORITIES,\n                \"default\": LametricPriority.INFO,\n            },\n            \"icon\": {\n                \"name\": _(\"Custom Icon\"),\n                \"type\": \"string\",\n            },\n            \"icon_type\": {\n                \"name\": _(\"Icon Type\"),\n                \"type\": \"choice:string\",\n                \"values\": LAMETRIC_ICON_TYPES,\n                \"default\": LametricIconType.NONE,\n            },\n            \"mode\": {\n                \"name\": _(\"Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": LAMETRIC_MODES,\n                \"default\": LametricMode.DEVICE,\n            },\n            \"sound\": {\n                \"name\": _(\"Sound\"),\n                \"type\": \"string\",\n            },\n            # Lifetime is in seconds\n            \"cycles\": {\n                \"name\": _(\"Cycles\"),\n                \"type\": \"int\",\n                \"min\": 0,\n                \"default\": 1,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        apikey=None,\n        app_token=None,\n        app_id=None,\n        app_ver=None,\n        priority=None,\n        icon=None,\n        icon_type=None,\n        sound=None,\n        mode=None,\n        cycles=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize LaMetric Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.mode = (\n            mode.strip().lower()\n            if isinstance(mode, str)\n            else self.template_args[\"mode\"][\"default\"]\n        )\n\n        # Default Cloud Argument\n        self.lametric_app_id = None\n        self.lametric_app_ver = None\n        self.lametric_app_access_token = None\n\n        # Default Device/Cloud Argument\n        self.lametric_apikey = None\n\n        if self.mode not in LAMETRIC_MODES:\n            msg = f\"An invalid LaMetric Mode ({mode}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if self.mode == LametricMode.CLOUD:\n            try:\n                results = LAMETRIC_APP_ID_DETECTOR_RE.match(app_id)\n            except TypeError:\n                msg = (\n                    \"An invalid LaMetric Application ID \"\n                    f\"({app_id}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n            # Detect our Access Token\n            self.lametric_app_access_token = validate_regex(\n                app_token, *self.template_tokens[\"app_token\"][\"regex\"]\n            )\n            if not self.lametric_app_access_token:\n                msg = (\n                    \"An invalid LaMetric Application Access Token \"\n                    f\"({app_token}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            # If app_ver is specified, it over-rides all\n            if app_ver:\n                self.lametric_app_ver = validate_regex(\n                    app_ver, *self.template_tokens[\"app_ver\"][\"regex\"]\n                )\n                if not self.lametric_app_ver:\n                    msg = (\n                        \"An invalid LaMetric Application Version \"\n                        f\"({app_ver}) was specified.\"\n                    )\n                    self.logger.warning(msg)\n                    raise TypeError(msg)\n\n            else:\n                # If app_ver wasn't specified, we parse it from the\n                # Application ID\n                self.lametric_app_ver = (\n                    results.group(\"app_ver\")\n                    if results.group(\"app_ver\")\n                    else self.template_tokens[\"app_ver\"][\"default\"]\n                )\n\n            # Store our Application ID\n            self.lametric_app_id = results.group(\"app_id\")\n\n        if self.mode == LametricMode.DEVICE:\n            self.lametric_apikey = validate_regex(apikey)\n            if not self.lametric_apikey:\n                msg = (\n                    \"An invalid LaMetric Device API Key \"\n                    f\"({apikey}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        if priority not in LAMETRIC_PRIORITIES:\n            self.priority = self.template_args[\"priority\"][\"default\"]\n\n        else:\n            self.priority = priority\n\n        # assign our icon (if it was defined); we also eliminate\n        # any hashtag (#) entries that might be present\n        self.icon = (\n            re.search(r\"[#\\s]*(?P<value>.+?)\\s*$\", icon).group(\"value\")\n            if isinstance(icon, str)\n            else None\n        )\n\n        if icon_type not in LAMETRIC_ICON_TYPES:\n            self.icon_type = self.template_args[\"icon_type\"][\"default\"]\n\n        else:\n            self.icon_type = icon_type\n\n        # The number of times the message should be displayed\n        self.cycles = (\n            self.template_args[\"cycles\"][\"default\"]\n            if not (\n                isinstance(cycles, int)\n                and cycles > self.template_args[\"cycles\"][\"min\"]\n            )\n            else cycles\n        )\n\n        self.sound = None\n        if isinstance(sound, str):\n            # If sound is set, get it's match\n            self.sound = self.sound_lookup(sound.strip().lower())\n            if self.sound is None:\n                self.logger.warning(\n                    f\"An invalid LaMetric sound ({sound}) was specified.\"\n                )\n        return\n\n    @staticmethod\n    def sound_lookup(lookup):\n        \"\"\"A simple match function that takes string and returns the\n        LametricSound object it was found in.\"\"\"\n\n        for x in LAMETRIC_SOUNDS:\n            match = next((f for f in x[1] if f.startswith(lookup)), None)\n            if match:\n                # We're done\n                return x\n\n        # No match was found\n        return None\n\n    def _cloud_notification_payload(self, body, notify_type, headers):\n        \"\"\"Return URL and payload for cloud directed requests.\"\"\"\n\n        # Update header entries\n        headers.update({\n            \"X-Access-Token\": self.lametric_apikey,\n        })\n\n        if self.sound:\n            self.logger.warning(\n                \"LaMetric sound setting is unavailable in Cloud mode\"\n            )\n\n        if self.priority != self.template_args[\"priority\"][\"default\"]:\n            self.logger.warning(\n                \"LaMetric priority setting is unavailable in Cloud mode\"\n            )\n\n        if self.icon_type != self.template_args[\"icon_type\"][\"default\"]:\n            self.logger.warning(\n                \"LaMetric icon_type setting is unavailable in Cloud mode\"\n            )\n\n        if self.cycles != self.template_args[\"cycles\"][\"default\"]:\n            self.logger.warning(\n                \"LaMetric cycle settings is unavailable in Cloud mode\"\n            )\n\n        # Assign our icon if the user specified a custom one, otherwise\n        # choose from our pre-set list (based on notify_type)\n        icon = (\n            self.icon\n            if self.icon\n            else self.lametric_icon_id_mapping[notify_type]\n        )\n\n        # Our Payload\n        # Cloud Notifications don't have as much functionality\n        # You can not set priority and/or sound\n        payload = {\n            \"frames\": [{\n                \"icon\": icon,\n                \"text\": body,\n                \"index\": 0,\n            }]\n        }\n\n        # Prepare our Cloud Notify URL\n        notify_url = self.cloud_notify_url.format(\n            app_id=self.lametric_app_id, app_ver=self.lametric_app_ver\n        )\n\n        # Return request parameters\n        return (notify_url, None, payload)\n\n    def _device_notification_payload(self, body, notify_type, headers):\n        \"\"\"Return URL and Payload for Device directed requests.\"\"\"\n\n        # Assign our icon if the user specified a custom one, otherwise\n        # choose from our pre-set list (based on notify_type)\n        icon = (\n            self.icon\n            if self.icon\n            else self.lametric_icon_id_mapping[notify_type]\n        )\n\n        # Our Payload\n        payload = {\n            # Priority of the message\n            \"priority\": self.priority,\n            # Icon Type: Represents the nature of notification\n            \"icon_type\": self.icon_type,\n            # The time notification lives in queue to be displayed in\n            # milliseconds (ms). The default lifetime is 2 minutes (120000ms).\n            # If notification stayed in queue for longer than lifetime\n            # milliseconds - it will not be displayed.\n            \"lifetime\": 120000,\n            \"model\": {\n                # cycles - the number of times message should be displayed. If\n                # cycles is set to 0, notification will stay on the screen\n                # until user dismisses it manually. By default it is set to 1.\n                \"cycles\": self.cycles,\n                \"frames\": [{\n                    \"icon\": icon,\n                    \"text\": body,\n                }],\n            },\n        }\n\n        if self.sound:\n            # Sound was set, so add it to the payload\n            payload[\"model\"][\"sound\"] = {\n                # The sound category\n                \"category\": self.sound[0],\n                # The first element of our tuple is always the id\n                \"id\": self.sound[1][0],\n                # repeat - defines the number of times sound must be played.\n                # If set to 0 sound will be played until notification is\n                # dismissed. By default the value is set to 1.\n                \"repeat\": 1,\n            }\n\n        if not self.user:\n            # Use default user if there wasn't one otherwise specified\n            self.user = self.default_device_user\n\n        # Prepare our authentication\n        auth = (self.user, self.password)\n\n        # Prepare our Direct Access Notify URL\n        notify_url = self.device_notify_url.format(\n            schema=\"https\" if self.secure else \"http\",\n            host=self.host,\n            port=\":{}\".format(\n                self.port\n                if self.port\n                else self.template_tokens[\"port\"][\"default\"]\n            ),\n        )\n\n        # Return request parameters\n        return (notify_url, auth, payload)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform LaMetric Notification.\"\"\"\n\n        # Prepare our headers:\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n            \"Cache-Control\": \"no-cache\",\n        }\n\n        # Depending on the mode, the payload is gathered by\n        # - _device_notification_payload()\n        # - _cloud_notification_payload()\n        (notify_url, auth, payload) = getattr(\n            self, f\"_{self.mode}_notification_payload\"\n        )(body=body, notify_type=notify_type, headers=headers)\n\n        self.logger.debug(\n            \"LaMetric POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"LaMetric Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=dumps(payload),\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            # An ideal response would be:\n            # {\n            #   \"success\": {\n            #     \"id\": \"<notification id>\"\n            #   }\n            # }\n\n            if r.status_code not in (\n                requests.codes.created,\n                requests.codes.ok,\n            ):\n                # We had a problem\n                status_str = NotifyLametric.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send LaMetric notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent LaMetric notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending LaMetric \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        if self.mode == LametricMode.DEVICE:\n            return (\n                self.secure_protocol if self.secure else self.protocol,\n                self.user,\n                self.lametric_apikey,\n                self.host,\n                (\n                    self.port\n                    if self.port\n                    else (\n                        443\n                        if self.secure\n                        else self.template_tokens[\"port\"][\"default\"]\n                    )\n                ),\n            )\n\n        return (\n            self.protocol,\n            self.lametric_app_access_token,\n            self.lametric_app_id,\n            self.lametric_app_ver,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"mode\": self.mode,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.icon:\n            # Assign our icon IF one was specified\n            params[\"icon\"] = self.icon\n\n        if self.mode == LametricMode.CLOUD:\n            # Upstream/LaMetric App Return\n            return \"{schema}://{token}@{app_id}/{app_ver}/?{params}\".format(\n                schema=self.protocol,\n                token=self.pprint(\n                    self.lametric_app_access_token, privacy, safe=\"\"\n                ),\n                app_id=self.pprint(self.lametric_app_id, privacy, safe=\"\"),\n                app_ver=NotifyLametric.quote(self.lametric_app_ver, safe=\"\"),\n                params=NotifyLametric.urlencode(params),\n            )\n\n        #\n        # If we reach here then we're dealing with LametricMode.DEVICE\n        #\n        if self.priority != self.template_args[\"priority\"][\"default\"]:\n            params[\"priority\"] = self.priority\n\n        if self.icon_type != self.template_args[\"icon_type\"][\"default\"]:\n            params[\"icon_type\"] = self.icon_type\n\n        if self.cycles != self.template_args[\"cycles\"][\"default\"]:\n            params[\"cycles\"] = self.cycles\n\n        if self.sound:\n            # Store our sound entry\n            # The first element of our tuple is always the id\n            params[\"sound\"] = self.sound[1][0]\n\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{apikey}@\".format(\n                user=NotifyLametric.quote(self.user, safe=\"\"),\n                apikey=self.pprint(self.lametric_apikey, privacy, safe=\"\"),\n            )\n        else:  # self.apikey is set\n            auth = \"{apikey}@\".format(\n                apikey=self.pprint(self.lametric_apikey, privacy, safe=\"\"),\n            )\n\n        # Local Return\n        return \"{schema}://{auth}{hostname}{port}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None\n                or self.port == self.template_tokens[\"port\"][\"default\"]\n                else f\":{self.port}\"\n            ),\n            params=NotifyLametric.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if results.get(\"user\") and not results.get(\"password\"):\n            # Handle URL like:\n            # schema://user@host\n\n            # This becomes the password\n            results[\"password\"] = results[\"user\"]\n            results[\"user\"] = None\n\n        # Get unquoted entries\n        entries = NotifyLametric.split_path(results[\"fullpath\"])\n\n        # Priority Handling\n        if \"priority\" in results[\"qsd\"] and results[\"qsd\"][\"priority\"]:\n            results[\"priority\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"priority\"].strip().lower()\n            )\n\n        # Icon Type\n        if \"icon\" in results[\"qsd\"] and results[\"qsd\"][\"icon\"]:\n            results[\"icon\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"icon\"].strip().lower()\n            )\n\n        # Icon Type\n        if \"icon_type\" in results[\"qsd\"] and results[\"qsd\"][\"icon_type\"]:\n            results[\"icon_type\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"icon_type\"].strip().lower()\n            )\n\n        # Sound\n        if \"sound\" in results[\"qsd\"] and results[\"qsd\"][\"sound\"]:\n            results[\"sound\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"sound\"].strip().lower()\n            )\n\n        # API Key (Device Mode)\n        if \"apikey\" in results[\"qsd\"] and results[\"qsd\"][\"apikey\"]:\n            # Extract API Key from an argument\n            results[\"apikey\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"apikey\"]\n            )\n\n        # App ID\n        if \"app\" in results[\"qsd\"] and results[\"qsd\"][\"app\"]:\n\n            # Extract the App ID from an argument\n            results[\"app_id\"] = NotifyLametric.unquote(results[\"qsd\"][\"app\"])\n\n        # App Version\n        if \"app_ver\" in results[\"qsd\"] and results[\"qsd\"][\"app_ver\"]:\n\n            # Extract the App ID from an argument\n            results[\"app_ver\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"app_ver\"]\n            )\n\n        elif entries:\n            # Store our app id\n            results[\"app_ver\"] = entries.pop(0)\n\n        if \"token\" in results[\"qsd\"] and results[\"qsd\"][\"token\"]:\n            # Extract Application Access Token from an argument\n            results[\"app_token\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"token\"]\n            )\n\n        # Mode override\n        if \"mode\" in results[\"qsd\"] and results[\"qsd\"][\"mode\"]:\n            results[\"mode\"] = NotifyLametric.unquote(\n                results[\"qsd\"][\"mode\"].strip().lower()\n            )\n        else:\n            # We can try to detect the mode based on the validity of the\n            # hostname. We can also scan the validity of the Application\n            # Access token\n            #\n            # This isn't a surfire way to do things though; it's best to\n            # specify the mode= flag\n            results[\"mode\"] = (\n                LametricMode.DEVICE\n                if (\n                    (\n                        is_hostname(results[\"host\"])\n                        or is_ipaddr(results[\"host\"])\n                    )\n                    and\n                    # make sure password is not an Access Token\n                    (\n                        results[\"password\"]\n                        and not LAMETRIC_IS_APP_TOKEN.match(\n                            results[\"password\"]\n                        )\n                    )\n                    and\n                    # Scan for app_ flags\n                    next((f for f in results if f.startswith(\"app_\")), None)\n                    is None\n                )\n                else LametricMode.CLOUD\n            )\n\n        # Handle defaults if not set\n        if results[\"mode\"] == LametricMode.DEVICE:\n            # Device Mode Defaults\n            if \"apikey\" not in results:\n                results[\"apikey\"] = NotifyLametric.unquote(results[\"password\"])\n\n        else:\n            # CLOUD Mode Defaults\n            if \"app_id\" not in results:\n                results[\"app_id\"] = NotifyLametric.unquote(results[\"host\"])\n            if \"app_token\" not in results:\n                results[\"app_token\"] = NotifyLametric.unquote(\n                    results[\"password\"]\n                )\n\n        # Set cycles\n        with contextlib.suppress(TypeError, ValueError):\n            results[\"cycles\"] = abs(int(results[\"qsd\"].get(\"cycles\")))\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support\n           https://developer.lametric.com/api/v1/dev/\\\n                   widget/update/com.lametric.{APP_ID}/1\n\n           https://developer.lametric.com/api/v1/dev/\\\n                   widget/update/com.lametric.{APP_ID}/{APP_VER}\n        \"\"\"\n\n        # If users do provide the Native URL they wll also want to add\n        # ?token={APP_ACCESS_TOKEN} to the parameters at the end or the\n        # URL will fail to load in later stages.\n        result = re.match(\n            r\"^http(?P<secure>s)?://(?P<host>[^/]+)\"\n            r\"/api/(?P<api_ver>v[1-9]*[0-9]+)\"\n            r\"/dev/widget/update/\"\n            r\"com\\.lametric\\.(?P<app_id>[0-9a-z.-]{1,64})\"\n            r\"(/(?P<app_ver>[1-9][0-9]*))?/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyLametric.parse_url(\n                \"{schema}://{app_id}{app_ver}/{params}\".format(\n                    schema=(\n                        NotifyLametric.secure_protocol\n                        if result.group(\"secure\")\n                        else NotifyLametric.protocol\n                    ),\n                    app_id=result.group(\"app_id\"),\n                    app_ver=(\n                        \"/{}\".format(result.group(\"app_ver\"))\n                        if result.group(\"app_ver\")\n                        else \"\"\n                    ),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/lark.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Details at:\n# https://open.larksuite.com/document/client-docs/bot-v3/add-bot\n\nimport json\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyLark(NotifyBase):\n    \"\"\"A wrapper for Lark (Feishu) Notifications via Webhook.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Lark (Feishu)\")\n\n    service_url = \"https://open.larksuite.com/\"\n\n    # The default protocol\n    secure_protocol = \"lark\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/lark/\"\n\n    # This is the static part of the webhook URL; only the token varies.\n    notify_url = \"https://open.larksuite.com/open-apis/bot/v2/hook/\"\n\n    # Define object templates\n    templates = (\"{schema}://{token}\",)\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Bot Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9-]+$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize Email Object.\n\n        The smtp_host and secure_mode can be automatically detected depending\n        on how the URL was built\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # The token associated with the account\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Lark Bot Token token specified ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.webhook_url = f\"{self.notify_url}{self.token}\"\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n        return (\n            f\"{self.secure_protocol}://\"\n            f\"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/\"\n            f\"?{NotifyLark.urlencode(params)}\"\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another similar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        self.throttle()\n\n        payload = {\n            \"msg_type\": \"text\",\n            \"content\": {\"text\": f\"{title}\\n{body}\" if title else body},\n        }\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                self.webhook_url,\n                headers=headers,\n                data=json.dumps(payload),\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                self.logger.warning(\n                    \"Lark notification failed: %d - %s\", r.status_code, r.text\n                )\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(f\"Lark Exception: {e}\")\n            return False\n\n        self.logger.info(\"Lark notification sent successfully.\")\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another similar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Set our token if found as an argument\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyLark.unquote(results[\"qsd\"][\"token\"])\n\n        else:\n            # Fall back to hose (if defined here)\n            results[\"token\"] = NotifyLark.unquote(results[\"host\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://open.larksuite.com/open-apis/bot/v2/hook//WEBHOOK_TOKEN\n        \"\"\"\n        match = re.match(\n            r\"^https://open\\.larksuite\\.com/open-apis/bot/v2/hook/([\\w-]+)$\",\n            url,\n            re.I,\n        )\n        if not match:\n            return None\n\n        return NotifyLark.parse_url(\n            f\"{NotifyLark.secure_protocol}://{match.group(1)}\"\n        )\n"
  },
  {
    "path": "apprise/plugins/line.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n#\n# API Docs: https://developers.line.biz/en/reference/messaging-api/\n\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Used to break path apart into list of streams\nTARGET_LIST_DELIM = re.compile(r\"[ \\t\\r\\n,#\\\\/]+\")\n\n\nclass NotifyLine(NotifyBase):\n    \"\"\"A wrapper for Line Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Line\"\n\n    # The services URL\n    service_url = \"https://line.me/\"\n\n    # Secure Protocol\n    secure_protocol = \"line\"\n\n    # The URL refererenced for remote Notifications\n    notify_url = \"https://api.line.me/v2/bot/message/push\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/line/\"\n\n    # We don't support titles for Line notifications\n    title_maxlen = 0\n\n    # Maximum body length is 5000\n    body_maxlen = 5000\n\n    # Allows the user to specify the NotifyImageSize object; this is supported\n    # through the webhook\n    image_size = NotifyImageSize.XY_128\n\n    # Define object templates\n    templates = (\"{schema}://{token}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    def __init__(self, token, targets=None, include_image=True, **kwargs):\n        \"\"\"Initialize Line Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Long-Lived Access token (generated from User Profile)\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = f\"An invalid Access Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Display our Apprise Image\n        self.include_image = include_image\n\n        # Set up our targets\n        self.targets = parse_list(targets)\n\n        # A dictionary of cached users\n        self.__cached_users = {}\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send our Line Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no Line targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.token}\",\n        }\n\n        # Prepare our persistent_notification.create payload\n        payload = {\n            \"to\": None,\n            \"messages\": [{\n                \"type\": \"text\",\n                \"text\": body,\n                \"sender\": {\n                    \"name\": self.app_id,\n                },\n            }],\n        }\n\n        # Acquire our image url if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        if image_url:\n            payload[\"messages\"][0][\"sender\"][\"iconUrl\"] = image_url\n\n        # Create a copy of the target list\n        targets = list(self.targets)\n        while len(targets):\n            target = targets.pop(0)\n\n            payload[\"to\"] = target\n\n            self.logger.debug(\n                \"Line POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Line Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyLine.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Line notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Line notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Line \"\n                    f\"notification to {target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{token}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            # never encode hostname since we're expecting it to be a valid one\n            token=self.pprint(\n                self.token, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            targets=\"/\".join(\n                [self.pprint(x, privacy, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyLine.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get unquoted entries\n        results[\"targets\"] = NotifyLine.split_path(results[\"fullpath\"])\n\n        # The 'token' makes it easier to use yaml configuration\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyLine.unquote(results[\"qsd\"][\"token\"])\n        else:\n            results[\"token\"] = NotifyLine.unquote(results[\"host\"])\n\n            # Line Long Lived Tokens included forward slashes in them.\n            # As a result we need to parse further into our path and look\n            # for the entry that ends in an equal symbol.\n            if not results[\"token\"].endswith(\"=\"):\n                for index, entry in enumerate(\n                    list(results[\"targets\"]), start=1\n                ):\n                    if entry.endswith(\"=\"):\n                        # Found\n                        results[\"token\"] += \"/\" + \"/\".join(\n                            results[\"targets\"][0:index]\n                        )\n                        results[\"targets\"] = results[\"targets\"][index:]\n                        break\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += list(\n                filter(\n                    bool,\n                    TARGET_LIST_DELIM.split(\n                        NotifyLine.unquote(results[\"qsd\"][\"to\"])\n                    ),\n                )\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/macosx.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport os\nimport platform\nimport subprocess\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n# Default our global support flag\nNOTIFY_MACOSX_SUPPORT_ENABLED = False\n\n\nif platform.system() == \"Darwin\":\n    # Check this is Mac OS X 10.8, or higher\n    major, minor = platform.mac_ver()[0].split(\".\")[:2]\n\n    # Toggle our enabled flag, if version is correct and executable\n    # found. This is done in such a way to provide verbosity to the\n    # end user, so they know why it may or may not work for them.\n    NOTIFY_MACOSX_SUPPORT_ENABLED = int(major) > 10 or (\n        int(major) == 10 and int(minor) >= 8\n    )\n\n\nclass NotifyMacOSX(NotifyBase):\n    \"\"\"A wrapper for the MacOS X terminal-notifier tool.\n\n    Source: https://github.com/julienXX/terminal-notifier\n    \"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_MACOSX_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"details\": _(\n            \"Only works with Mac OS X 10.8 and higher. Additionally \"\n            \" requires that /usr/local/bin/terminal-notifier is locally \"\n            \"accessible.\"\n        )\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"MacOSX Notification\")\n\n    # The services URL\n    service_url = \"https://github.com/julienXX/terminal-notifier\"\n\n    # The default protocol\n    protocol = \"macosx\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/macosx/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # Disable throttle rate for MacOSX requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Limit results to just the first 10 line otherwise there is just to much\n    # content to display\n    body_max_line_count = 10\n\n    # No URL Identifier will be defined for this service as there simply isn't\n    # enough details to uniquely identify one dbus:// from another.\n    url_identifier = False\n\n    # The possible paths to the terminal-notifier\n    notify_paths = (\n        \"/opt/homebrew/bin/terminal-notifier\",\n        \"/usr/local/bin/terminal-notifier\",\n        \"/usr/bin/terminal-notifier\",\n        \"/bin/terminal-notifier\",\n        \"/opt/local/bin/terminal-notifier\",\n    )\n\n    # Define object templates\n    templates = (\"{schema}://\",)\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            # Play the NAME sound when the notification appears.\n            # Sound names are listed in Sound Preferences.\n            # Use 'default' for the default sound.\n            \"sound\": {\n                \"name\": _(\"Sound\"),\n                \"type\": \"string\",\n            },\n            \"click\": {\n                \"name\": _(\"Open/Click URL\"),\n                \"type\": \"string\",\n            },\n        },\n    )\n\n    def __init__(self, sound=None, include_image=True, click=None, **kwargs):\n        \"\"\"Initialize MacOSX Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        # Track whether we want to add an image to the notification.\n        self.include_image = include_image\n\n        # Acquire the path to the `terminal-notifier` program.\n        self.notify_path = next(  # pragma: no branch\n            (p for p in self.notify_paths if os.access(p, os.X_OK)), None\n        )\n\n        # Click URL\n        # Allow user to provide the `--open` argument on the notify wrapper\n        self.click = click\n\n        # Set sound object (no q/a for now)\n        self.sound = sound\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform MacOSX Notification.\"\"\"\n\n        if not (self.notify_path and os.access(self.notify_path, os.X_OK)):\n            self.logger.warning(\n                \"MacOSX Notifications requires one of the following to \"\n                \"be in place: '{}'.\".format(\"', '\".join(self.notify_paths))\n            )\n            return False\n\n        # Start with our notification path\n        cmd = [\n            self.notify_path,\n            \"-message\",\n            body,\n        ]\n\n        # Title is an optional switch\n        if title:\n            cmd.extend([\"-title\", title])\n\n        if self.click:\n            cmd.extend([\"-open\", self.click])\n\n        # The sound to play\n        if self.sound:\n            cmd.extend([\"-sound\", self.sound])\n\n        # Support any defined images if set\n        image_path = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n        if image_path:\n            cmd.extend([\"-appIcon\", image_path])\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # Capture some output for helpful debugging later on\n        self.logger.debug(\"MacOSX CMD: {}\".format(\" \".join(cmd)))\n\n        # Send our notification\n        output = subprocess.Popen(cmd)\n\n        # Wait for process to complete\n        output.wait()\n\n        if output.returncode:\n            self.logger.warning(\"Failed to send MacOSX notification.\")\n            self.logger.exception(\"MacOSX Exception\")\n            return False\n\n        self.logger.info(\"Sent MacOSX notification.\")\n        return True\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parametrs\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        if self.click:\n            params[\"click\"] = self.click\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.sound:\n            # Store our sound\n            params[\"sound\"] = self.sound\n\n        return f\"{self.protocol}://_/?{NotifyMacOSX.urlencode(params)}\"\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"There are no parameters nessisary for this protocol; simply having\n        gnome:// is all you need.\n\n        This function just makes sure that is in place.\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # Support 'click'\n        if \"click\" in results[\"qsd\"] and len(results[\"qsd\"][\"click\"]):\n            results[\"click\"] = NotifyMacOSX.unquote(results[\"qsd\"][\"click\"])\n\n        # Support 'sound'\n        if \"sound\" in results[\"qsd\"] and len(results[\"qsd\"][\"sound\"]):\n            results[\"sound\"] = NotifyMacOSX.unquote(results[\"qsd\"][\"sound\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/mailgun.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Signup @ https://www.mailgun.com/\n#\n# Each domain will have an API key associated with it. If you sign up you'll\n# get a sandbox domain to use.  Or if you set up your own, they'll have\n# api keys associated with them too.  Find your API key out by visiting\n#    https://app.mailgun.com/app/domains\n#\n# From here you can click on the domain you're interested in. You can acquire\n# the API Key from here which will look something like:\n#    4b4f2918c6c21ba0a26ad2af73c07f4d-dk5f51da-8f91a0df\n#\n# You'll also need to know the domain that is associated with your API key.\n# This will be obvious with a paid account because it will be the domain name\n# you've registered with them.   But if you're using a test account, it will\n# be name of the sandbox you've set up such as:\n#    sandbox74bda3414c06kb5acb946.mailgun.org\n#\n# Knowing this, you can buid your mailgun url as follows:\n#  mailgun://{user}@{domain}/{apikey}\n#  mailgun://{user}@{domain}/{apikey}/{email}\n#\n# You can email as many addresses as you want as:\n#  mailgun://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN}\n#\n#  The {user}@{domain} effectively assembles the 'from' email address\n#  the email will be transmitted from.  If no email address is specified\n#  then it will also become the 'to' address as well.\n#\nfrom email.utils import formataddr\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..logger import logger\nfrom ..utils.parse import is_email, parse_bool, parse_emails, validate_regex\nfrom .base import NotifyBase\n\n# Provide some known codes Mailgun uses and what they translate to:\n# Based on https://documentation.mailgun.com/en/latest/api-intro.html#errors\nMAILGUN_HTTP_ERROR_MAP = {\n    400: \"A bad request was made to the server.\",\n    401: \"The provided API Key was not valid.\",\n    402: \"The request failed for a reason out of your control.\",\n    404: \"The requested API query is not valid.\",\n    413: \"Provided attachment is to big.\",\n}\n\n\n# Priorities\nclass MailgunRegion:\n    US = \"us\"\n    EU = \"eu\"\n\n\n# Mailgun APIs\nMAILGUN_API_LOOKUP = {\n    MailgunRegion.US: \"https://api.mailgun.net/v3/\",\n    MailgunRegion.EU: \"https://api.eu.mailgun.net/v3/\",\n}\n\n# A List of our regions we can use for verification\nMAILGUN_REGIONS = (\n    MailgunRegion.US,\n    MailgunRegion.EU,\n)\n\n\nclass NotifyMailgun(NotifyBase):\n    \"\"\"A wrapper for Mailgun Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Mailgun\"\n\n    # The services URL\n    service_url = \"https://www.mailgun.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"mailgun\"\n\n    # Mailgun advertises they allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/mailgun/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Default Notify Format\n    notify_format = NotifyFormat.HTML\n\n    # The maximum amount of emails that can reside within a single\n    # batch transfer\n    default_batch_size = 2000\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}@{host}:{apikey}/\",\n        \"{schema}://{user}@{host}:{apikey}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"host\": {\n                \"name\": _(\"Domain\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"name\": {\n                \"name\": _(\"From Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"from_addr\",\n            },\n            \"from\": {\n                \"alias_of\": \"name\",\n            },\n            \"region\": {\n                \"name\": _(\"Region Name\"),\n                \"type\": \"choice:string\",\n                \"values\": MAILGUN_REGIONS,\n                \"default\": MailgunRegion.US,\n                \"map_to\": \"region_name\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"Email Header\"),\n            \"prefix\": \"+\",\n        },\n        \"tokens\": {\n            \"name\": _(\"Template Tokens\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        apikey,\n        targets,\n        cc=None,\n        bcc=None,\n        from_addr=None,\n        region_name=None,\n        headers=None,\n        tokens=None,\n        batch=False,\n        **kwargs,\n    ):\n        \"\"\"Initialize Mailgun Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid Mailgun API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Validate our username\n        if not self.user:\n            msg = \"No Mailgun username was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire Email 'To'\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        self.tokens = {}\n        if tokens:\n            # Store our template tokens\n            self.tokens.update(tokens)\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        # Store our region\n        try:\n            self.region_name = (\n                NotifyMailgun.template_args[\"region\"][\"default\"]\n                if region_name is None\n                else region_name.lower()\n            )\n\n            if self.region_name not in MAILGUN_REGIONS:\n                # allow the outer except to handle this common response\n                raise\n        except:\n            # Invalid region specified\n            msg = f\"The Mailgun region specified ({region_name}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        # Get our From username (if specified)\n        self.from_addr = [self.app_id, f\"{self.user}@{self.host}\"]\n\n        if from_addr:\n            result = is_email(from_addr)\n            if result:\n                self.from_addr = (\n                    result[\"name\"] if result[\"name\"] else False,\n                    result[\"full_email\"],\n                )\n            else:\n                self.from_addr[0] = from_addr\n\n        if not is_email(self.from_addr[1]):\n            # Parse Source domain based on from_addr\n            msg = f\"Invalid ~From~ email format: {self.from_addr}\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if targets:\n            # Validate recipients (to:) and drop bad ones:\n            for recipient in parse_emails(targets):\n                result = is_email(recipient)\n                if result:\n                    self.targets.append((\n                        result[\"name\"] if result[\"name\"] else False,\n                        result[\"full_email\"],\n                    ))\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid To email ({recipient}) specified.\",\n                )\n\n        else:\n            # If our target email list is empty we want to add ourselves to it\n            self.targets.append((False, self.from_addr[1]))\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n            email = is_email(recipient)\n            if email:\n                self.cc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n            email = is_email(recipient)\n            if email:\n                self.bcc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Mailgun Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\"There are no Email recipients to notify\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n        }\n\n        # Track our potential files\n        files = {}\n\n        if attach and self.attachment_support:\n            for idx, attachment in enumerate(attach):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Preparing Mailgun attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n                # Prepare our filename\n                filename = (\n                    attachment.name\n                    if attachment.name\n                    else f\"file{idx + 1:03}.dat\"\n                )\n\n                try:\n                    files[f\"attachment[{idx}]\"] = (\n                        filename,\n                        # file handle is safely closed through this code\n                        # ignoring of SIM115 is intentional\n                        open(attachment.path, \"rb\"),  # noqa: SIM115\n                    )\n\n                except OSError as e:\n                    self.logger.warning(\n                        \"An I/O error occurred while opening {}.\".format(\n                            attachment.name if attachment else \"attachment\"\n                        )\n                    )\n                    self.logger.debug(f\"I/O Exception: {e!s}\")\n\n                    # tidy up any open files before we make our early\n                    # return\n                    for entry in files.values():\n                        self.logger.trace(f\"Closing attachment {entry[0]}\")\n                        entry[1].close()\n\n                    return False\n\n        reply_to = formataddr(self.from_addr, charset=\"utf-8\")\n\n        # Prepare our payload\n        payload = {\n            # pass skip-verification switch upstream too\n            \"o:skip-verification\": not self.verify_certificate,\n            # Base payload options\n            \"from\": reply_to,\n            \"subject\": title,\n        }\n\n        if self.notify_format == NotifyFormat.HTML:\n            payload[\"html\"] = body\n\n        else:\n            payload[\"text\"] = body\n\n        # Prepare our URL as it's based on our hostname\n        url = f\"{MAILGUN_API_LOOKUP[self.region_name]}{self.host}/messages\"\n\n        # Create a copy of the targets list\n        emails = list(self.targets)\n\n        for index in range(0, len(emails), batch_size):\n            # Initialize our cc list\n            cc = self.cc - self.bcc\n\n            # Initialize our bcc list\n            bcc = set(self.bcc)\n\n            # Initialize our to list\n            to = []\n\n            # Ensure we're pointed to the head of the attachment; this doesn't\n            # do much for the first iteration through this loop as we're\n            # already pointing there..., but it allows us to re-use the\n            # attachment over and over again without closing and then\n            # re-opening the same file again and again\n            for entry in files.values():\n                try:\n                    self.logger.trace(\n                        f\"Seeking to head of attachment {entry[0]}\"\n                    )\n                    entry[1].seek(0)\n\n                except OSError as e:\n                    self.logger.warning(\n                        \"An I/O error occurred seeking to head of attachment \"\n                        f\"{entry[0]}.\"\n                    )\n                    self.logger.debug(f\"I/O Exception: {e!s}\")\n\n                    # tidy up any open files before we make our early\n                    # return\n                    for entry in files.values():\n                        self.logger.trace(f\"Closing attachment {entry[0]}\")\n                        entry[1].close()\n\n                    return False\n\n            for to_addr in self.targets[index : index + batch_size]:\n                # Strip target out of cc list if in To\n                cc = cc - {to_addr[1]}\n\n                # Strip target out of bcc list if in To\n                bcc = bcc - {to_addr[1]}\n\n                # Prepare our `to`\n                to.append(formataddr(to_addr, charset=\"utf-8\"))\n\n            # Prepare our To\n            payload[\"to\"] = \",\".join(to)\n\n            if cc:\n                # Format our cc addresses to support the Name field\n                payload[\"cc\"] = \",\".join([\n                    formataddr(\n                        (self.names.get(addr, False), addr),\n                        charset=\"utf-8\",\n                    )\n                    for addr in cc\n                ])\n\n            # Format our bcc addresses to support the Name field\n            if bcc:\n                payload[\"bcc\"] = \",\".join(bcc)\n\n            # Store our token entries; users can reference these as %value%\n            # in their email message.\n            if self.tokens:\n                payload.update({f\"v:{k}\": v for k, v in self.tokens.items()})\n\n            # Store our header entries if defined into the payload\n            # in their payload\n            if self.headers:\n                payload.update({f\"h:{k}\": v for k, v in self.headers.items()})\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Mailgun POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Mailgun Payload: {payload}\")\n\n            # For logging output of success and errors; we get a head count\n            # of our outbound details:\n            verbose_dest = (\n                \", \".join(\n                    [x[1] for x in self.targets[index : index + batch_size]]\n                )\n                if len(self.targets[index : index + batch_size]) <= 3\n                else (\n                    f\"{len(self.targets[index:index + batch_size])} recipients\"\n                )\n            )\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    url,\n                    auth=(\"api\", self.apikey),\n                    data=payload,\n                    headers=headers,\n                    files=files if files else None,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code, MAILGUN_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Mailgun notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            verbose_dest,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent Mailgun notification to {verbose_dest}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending\"\n                    f\" Mailgun:{verbose_dest} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n            except OSError as e:\n                self.logger.warning(\n                    \"An I/O error occurred while reading attachments\"\n                )\n                self.logger.debug(f\"I/O Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        # Close any potential attachments that are still open\n        for entry in files.values():\n            self.logger.trace(f\"Closing attachment {entry[0]}\")\n            entry[1].close()\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.host,\n            self.apikey,\n            self.region_name,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"region\": self.region_name,\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Append our template tokens into our parameters\n        params.update({f\":{k}\": v for k, v in self.tokens.items()})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.from_addr[0]:\n            # from_addr specified; pass it back on the url\n            params[\"name\"] = self.from_addr[0]\n\n        if self.cc:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                \"{}{}\".format(\n                    \"\" if not e not in self.names else f\"{self.names[e]}:\",\n                    e,\n                )\n                for e in self.cc\n            ])\n\n        if self.bcc:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join(self.bcc)\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0][1] == self.from_addr\n        )\n\n        return \"{schema}://{user}@{host}/{apikey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            host=self.host,\n            user=NotifyMailgun.quote(self.user, safe=\"\"),\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=(\n                \"\"\n                if not has_targets\n                else \"/\".join([\n                    NotifyMailgun.quote(\n                        \"{}{}\".format(\"\" if not e[0] else f\"{e[0]}:\", e[1]),\n                        safe=\"\",\n                    )\n                    for e in self.targets\n                ])\n            ),\n            params=NotifyMailgun.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyMailgun.split_path(results[\"fullpath\"])\n\n        # Our very first entry is reserved for our api key\n        try:\n            results[\"apikey\"] = results[\"targets\"].pop(0)\n\n        except IndexError:\n            # We're done - no API Key found\n            results[\"apikey\"] = None\n\n        # Attempt to detect 'from' email address\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"from_addr\"] = NotifyMailgun.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n            if \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n                # Depricate use of both `from=` and `name=` in the same url as\n                # they will be synomomus of one another in the future.\n                results[\"from_addr\"] = formataddr(\n                    (\n                        NotifyMailgun.unquote(results[\"qsd\"][\"name\"]),\n                        results[\"from_addr\"],\n                    ),\n                    charset=\"utf-8\",\n                )\n                logger.warning(\n                    \"Mailgun name= and from= are synonymous; \"\n                    \"use one or the other.\"\n                )\n\n        elif \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n            # Extract from name to associate with from address\n            results[\"from_addr\"] = NotifyMailgun.unquote(\n                results[\"qsd\"][\"name\"]\n            )\n\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            # Acquire region if defined\n            results[\"region_name\"] = NotifyMailgun.unquote(\n                results[\"qsd\"][\"region\"]\n            )\n\n        # Handle 'to' email address\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(results[\"qsd\"][\"to\"])\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = results[\"qsd\"][\"cc\"]\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = results[\"qsd\"][\"bcc\"]\n\n        # Add our Meta Headers that the user can provide with their outbound\n        # emails\n        results[\"headers\"] = {\n            NotifyBase.unquote(x): NotifyBase.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Add our template tokens (if defined)\n        results[\"tokens\"] = {\n            NotifyBase.unquote(x): NotifyBase.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyMailgun.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/mastodon.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nfrom copy import deepcopy\nfrom datetime import datetime, timezone\nfrom json import dumps, loads\nimport re\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Accept:\n# - @username\n# - username\n# - username@host.com\n# - @username@host.com\nIS_USER = re.compile(\n    r\"^\\s*@?(?P<user>[A-Z0-9_]+(?:@(?P<host>[A-Z0-9_.-]+))?)$\", re.I\n)\n\nUSER_DETECTION_RE = re.compile(\n    r\"(@[A-Z0-9_]+(?:@[A-Z0-9_.-]+)?)(?=$|[\\s,.&()\\[\\]]+)\", re.I\n)\n\n\nclass MastodonMessageVisibility:\n    \"\"\"The visibility of any status message made.\"\"\"\n\n    # post visibility defaults to the accounts default-visibilty setting\n    DEFAULT = \"default\"\n\n    # post will be visible only to mentioned users\n    # similar to a Twitter DM\n    DIRECT = \"direct\"\n\n    # post will be visible only to followers\n    PRIVATE = \"private\"\n\n    # post will be public but not appear on the public timeline\n    UNLISTED = \"unlisted\"\n\n    # post will be public\n    PUBLIC = \"public\"\n\n\n# Define the types in a list for validation purposes\nMASTODON_MESSAGE_VISIBILITIES = (\n    MastodonMessageVisibility.DEFAULT,\n    MastodonMessageVisibility.DIRECT,\n    MastodonMessageVisibility.PRIVATE,\n    MastodonMessageVisibility.UNLISTED,\n    MastodonMessageVisibility.PUBLIC,\n)\n\n\nclass NotifyMastodon(NotifyBase):\n    \"\"\"A wrapper for Notify Mastodon Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Mastodon\"\n\n    # The services URL\n    service_url = \"https://joinmastodon.org\"\n\n    # The default protocol\n    protocol = (\"mastodon\", \"toot\")\n\n    # The default secure protocol\n    secure_protocol = (\"mastodons\", \"toots\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/mastodon/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allows the user to specify the NotifyImageSize object\n    # Allows the user to specify the NotifyImageSize object; this is supported\n    # through the webhook\n    image_size = NotifyImageSize.XY_128\n\n    # it is documented on the site that the maximum images per toot\n    # is 4 (unless it's a GIF, then it's only 1)\n    __toot_non_gif_images_batch = 4\n\n    # Mastodon API Reference To Acquire Current Users Information\n    # See: https://docs.joinmastodon.org/methods/accounts/\n    # Requires Scope Element: read:accounts\n    mastodon_whoami = \"/api/v1/accounts/verify_credentials\"\n\n    # URL for posting media files\n    mastodon_media = \"/api/v1/media\"\n\n    # URL for posting status messages\n    mastodon_toot = \"/api/v1/statuses\"\n\n    # URL for posting direct messages\n    mastodon_dm = \"/api/v1/dm\"\n\n    # The title is not used\n    title_maxlen = 0\n\n    # The maximum size of the message\n    body_maxlen = 500\n\n    # Default to text\n    notify_format = NotifyFormat.TEXT\n\n    # Mastodon is kind enough to return how many more requests we're allowed to\n    # continue to make within it's header response as:\n    # X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our\n    #                    rate-limit to be reset.\n    # X-Rate-Limit-Remaining: an integer identifying how many requests we're\n    #                        still allow to make.\n    request_rate_per_sec = 0\n\n    # For Tracking Purposes\n    ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)\n\n    # Default to 1000; users can send up to 1000 DM's and 2400 toot a day\n    # This value only get's adjusted if the server sets it that way\n    ratelimit_remaining = 1\n\n    # Define object templates\n    templates = (\n        \"{schema}://{token}@{host}\",\n        \"{schema}://{token}@{host}:{port}\",\n        \"{schema}://{token}@{host}/{targets}\",\n        \"{schema}://{token}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"visibility\": {\n                \"name\": _(\"Visibility\"),\n                \"type\": \"choice:string\",\n                \"values\": MASTODON_MESSAGE_VISIBILITIES,\n                \"default\": MastodonMessageVisibility.DEFAULT,\n            },\n            \"spoiler\": {\n                \"name\": _(\"Spoiler Text\"),\n                \"type\": \"string\",\n            },\n            \"key\": {\n                \"name\": _(\"Idempotency-Key\"),\n                \"type\": \"string\",\n            },\n            \"language\": {\n                \"name\": _(\"Language Code\"),\n                \"type\": \"string\",\n            },\n            \"cache\": {\n                \"name\": _(\"Cache Results\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"sensitive\": {\n                \"name\": _(\"Sensitive Attachments\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        token=None,\n        targets=None,\n        batch=True,\n        sensitive=None,\n        spoiler=None,\n        visibility=None,\n        cache=True,\n        key=None,\n        language=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify Mastodon Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Set our schema\n        self.schema = \"https\" if self.secure else \"http\"\n\n        # Initialize our cache value\n        self._whoami_cache = None\n\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = \"An invalid Mastodon Access Token was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if visibility:\n            # Input is a string; attempt to get the lookup from our\n            # sound mapping\n            vis = (\n                \"invalid\"\n                if not isinstance(visibility, str)\n                else visibility.lower().strip()\n            )\n\n            # This little bit of black magic allows us to match against\n            # against multiple versions of the same string\n            # ... etc\n            self.visibility = next(\n                (\n                    v\n                    for v in MASTODON_MESSAGE_VISIBILITIES\n                    if v.startswith(vis)\n                ),\n                None,\n            )\n\n            if self.visibility not in MASTODON_MESSAGE_VISIBILITIES:\n                msg = (\n                    f\"The Mastodon visibility specified ({visibility}) is\"\n                    \" invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        else:\n            self.visibility = self.template_args[\"visibility\"][\"default\"]\n\n        # Prepare our URL\n        self.api_url = f\"{self.schema}://{self.host}\"\n\n        if isinstance(self.port, int):\n            self.api_url += f\":{self.port}\"\n\n        # Set Cache Flag\n        self.cache = cache\n\n        # Prepare Image Batch Mode Flag\n        self.batch = (\n            self.template_args[\"batch\"][\"default\"] if batch is None else batch\n        )\n\n        # Images to be marked sensitive\n        self.sensitive = (\n            self.template_args[\"sensitive\"][\"default\"]\n            if sensitive is None\n            else sensitive\n        )\n\n        # Text marked as being a spoiler\n        self.spoiler = spoiler if isinstance(spoiler, str) else None\n\n        # Idempotency Key\n        self.idempotency_key = key if isinstance(key, str) else None\n\n        # Over-ride default language (ISO 639) (e.g: en, fr, es, etc)\n        self.language = language if isinstance(language, str) else None\n\n        # Our target users\n        self.targets = []\n\n        # Track any errors\n        has_error = False\n\n        # Identify our targets\n        for target in parse_list(targets):\n            match = IS_USER.match(target)\n            if match and match.group(\"user\"):\n                self.targets.append(\"@\" + match.group(\"user\"))\n                continue\n\n            has_error = True\n            self.logger.warning(\n                f\"Dropped invalid Mastodon user ({target}) specified.\",\n            )\n\n        if has_error and not self.targets:\n            # We have specified that we want to notify one or more individual\n            # and we failed to load any of them.  Since it's also valid to\n            # notify no one at all (which means we notify ourselves), it's\n            # important we don't switch from the users original intentions\n            msg = \"No Mastodon targets to notify.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol[0],\n            self.token,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"visibility\": self.visibility,\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"sensitive\": \"yes\" if self.sensitive else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.spoiler:\n            # Our Spoiler if one was specified\n            params[\"spoiler\"] = self.spoiler\n\n        if self.idempotency_key:\n            # Our Idempotency Key\n            params[\"key\"] = self.idempotency_key\n\n        if self.language:\n            # Override Language\n            params[\"language\"] = self.language\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{token}@{host}{port}/{targets}?{params}\".format(\n            schema=(\n                self.secure_protocol[0] if self.secure else self.protocol[0]\n            ),\n            token=self.pprint(\n                self.token, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            # never encode hostname since we're expecting it to be a valid one\n            host=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            targets=\"/\".join(\n                [NotifyMastodon.quote(x, safe=\"@\") for x in self.targets]\n            ),\n            params=NotifyMastodon.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Wrapper to _send since we can alert more then one channel.\"\"\"\n\n        # Build a list of our attachments\n        attachments = []\n\n        # Smart Target Detection for Direct Messages; this prevents us from\n        # adding @user entries that were already placed in the message body\n        users = set(USER_DETECTION_RE.findall(body))\n        targets = users - set(self.targets.copy())\n        if (\n            not self.targets\n            and self.visibility == MastodonMessageVisibility.DIRECT\n        ):\n\n            result = self._whoami()\n            if not result:\n                # Could not access our status\n                return False\n\n            myself = \"@\" + next(iter(result.keys()))\n            if myself in users:\n                targets.remove(myself)\n\n            else:\n                targets.add(myself)\n\n        if attach and self.attachment_support:\n            # We need to upload our payload first so that we can source it\n            # in remaining messages\n            for attachment in attach:\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                #\n                # Images (PNG, JPG, GIF) up to 8MB.\n                #  - Images will be downscaled to 1.6 megapixels (enough for a\n                #    1280x1280 image).\n                #  -  Up to 4 images can be attached.\n                #  -  Animated GIFs are converted to soundless MP4s like on\n                #     Imgur/Gfycat (GIFV).\n                #  - You can also upload soundless MP4 and WebM, which will\n                #     be handled the same way.\n                # Videos (MP4, M4V, MOV, WebM) up to 40MB.\n                #  - Video will be transcoded to H.264 MP4 with a maximum\n                #     bitrate of 1300kbps and framerate of 60fps.\n                # Audio (MP3, OGG, WAV, FLAC, OPUS, AAC, M4A, 3GP) up to 40MB.\n                #  - Audio will be transcoded to MP3 using V2 VBR (roughly\n                #      192kbps).\n                if not re.match(\n                    r\"^(image|video|audio)/.*\", attachment.mimetype, re.I\n                ):\n                    # Only support images at this time\n                    self.logger.warning(\n                        \"Ignoring unsupported Mastodon attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    continue\n\n                self.logger.debug(\n                    \"Preparing Mastodon attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n                # Upload our image and get our id associated with it\n                postokay, response = self._request(\n                    self.mastodon_media,\n                    payload=attachment,\n                )\n\n                if not postokay:\n                    # We can't post our attachment\n                    if response and \"authorized scopes\" in response.get(\n                        \"error\", \"\"\n                    ):\n                        self.logger.warning(\n                            \"Failed to Send Attachment to Mastodon: \"\n                            \"missing scope: write:media\"\n                        )\n\n                    # All other failures should cause us to abort\n                    return False\n\n                if not (isinstance(response, dict) and response.get(\"id\")):\n                    self.logger.debug(\n                        \"Could not attach the file to Mastodon: %s (mime=%s)\",\n                        attachment.name,\n                        attachment.mimetype,\n                    )\n                    continue\n\n                # If we get here, our output will look something like this:\n                # {\n                #  'id': '12345',\n                #  'type': 'image',\n                #  'url': 'https://.../6dad4663a.jpeg',\n                #  'preview_url': 'https://.../adde6dad4663a.jpeg',\n                #  'remote_url': None,\n                #  'preview_remote_url': None,\n                #  'text_url': None,\n                #  'meta': {\n                #     'original': {\n                #       'width': 640,\n                #       'height': 640,\n                #       'size': '640x640',\n                #       'aspect': 1.0\n                #      },\n                #     'small': {\n                #       'width': 400,\n                #       'height': 400,\n                #       'size': '400x400',\n                #       'aspect': 1.0\n                #      }\n                #  },\n                #  'description': None,\n                #  'blurhash': 'UmIsdJnT^mX4V@XQofnQ~Ebq%4o3ofnQjZbt'\n                # }\n                response.update({\n                    # Update our response to additionally include the\n                    # attachment details\n                    \"file_name\": attachment.name,\n                    \"file_mime\": attachment.mimetype,\n                    \"file_path\": attachment.path,\n                })\n\n                # Save our pre-prepared payload for attachment posting\n                attachments.append(response)\n\n        payload = {\n            \"status\": (\n                \"{} {}\".format(\" \".join(targets), body) if targets else body\n            ),\n            \"sensitive\": self.sensitive,\n        }\n\n        # Handle Visibility Flag\n        if self.visibility != MastodonMessageVisibility.DEFAULT:\n            payload[\"visibility\"] = self.visibility\n\n        # Set Spoiler text (if set)\n        if self.spoiler:\n            payload[\"spoiler_text\"] = self.spoiler\n\n        # Set Idempotency-Key (if set)\n        if self.idempotency_key:\n            payload[\"Idempotency-Key\"] = self.idempotency_key\n\n        # Set Language\n        if self.language:\n            payload[\"language\"] = self.language\n\n        payloads = []\n        if not attachments:\n            payloads.append(payload)\n\n        else:\n            # Group our images if batch is set to do so\n            batch_size = (\n                1 if not self.batch else self.__toot_non_gif_images_batch\n            )\n\n            # Track our batch control in our message generation\n            batches = []\n            batch = []\n            for attachment in attachments:\n                batch.append(attachment[\"id\"])\n                # Mastodon supports batching images together.  This allows\n                # the batching of multiple images together.  Mastodon also\n                # makes it clear that you can't batch `gif` files; they need\n                # to be separate.  So the below preserves the ordering that\n                # a user passed their attachments in.  if 4-non-gif images\n                # are passed, they are all part of a single message.\n                #\n                # however, if they pass in image, gif, image, gif.  The\n                # gif's inbetween break apart the batches so this would\n                # produce 4 separate toots.\n                #\n                # If you passed in, image, image, gif, image. <- This would\n                # produce 3 images (as the first 2 images could be lumped\n                # together as a batch)\n                if (\n                    not re.match(\n                        r\"^image/(png|jpe?g)\", attachment[\"file_mime\"], re.I\n                    )\n                    or len(batch) >= batch_size\n                ):\n                    batches.append(batch)\n                    batch = []\n\n            if batch:\n                batches.append(batch)\n\n            for no, media_ids in enumerate(batches):\n                payload_ = deepcopy(payload)\n                payload_[\"media_ids\"] = media_ids\n\n                if no or not body:\n                    # strip text and replace it with the image representation\n                    payload_[\"status\"] = f\"{no + 1:02d}/{len(batches):02d}\"\n                    # No longer sensitive information\n                    payload_[\"sensitive\"] = False\n                    if self.idempotency_key:\n                        # Support multiposts while a Idempotency Key has been\n                        # defined\n                        payload_[\"Idempotency-Key\"] = (\n                            f\"{self.idempotency_key}-part{no:02d}\"\n                        )\n                payloads.append(payload_)\n\n        # Error Tracking\n        has_error = False\n\n        for no, payload in enumerate(payloads, start=1):\n            postokay, response = self._request(self.mastodon_toot, payload)\n            if not postokay:\n                # Track our error\n                has_error = True\n\n                # We can't post our attachment\n                if response and \"authorized scopes\" in response.get(\n                    \"error\", \"\"\n                ):\n                    self.logger.warning(\n                        \"Failed to Send Status to Mastodon: \"\n                        \"missing scope: write:statuses\"\n                    )\n\n                continue\n\n            # Example Attachment Output:\n            # {\n            #    \"id\":\"109315796435904505\",\n            #    \"created_at\":\"2022-11-09T20:44:39.017Z\",\n            #    \"in_reply_to_id\":null,\n            #    \"in_reply_to_account_id\":null,\n            #    \"sensitive\":false,\n            #    \"spoiler_text\":\"\",\n            #    \"visibility\":\"public\",\n            #    \"language\":\"en\",\n            #    \"uri\":\"https://host/users/caronc/statuses/109315796435904505\",\n            #    \"url\":\"https://host/@caronc/109315796435904505\",\n            #    \"replies_count\":0,\n            #    \"reblogs_count\":0,\n            #    \"favourites_count\":0,\n            #    \"edited_at\":null,\n            #    \"favourited\":false,\n            #    \"reblogged\":false,\n            #    \"muted\":false,\n            #    \"bookmarked\":false,\n            #    \"pinned\":false,\n            #    \"content\":\"<p>test</p>\",\n            #    \"reblog\":null,\n            #    \"application\":{\n            #       \"name\":\"Apprise Notifications\",\n            #       \"website\":\"https://github.com/caronc/apprise\"\n            #    },\n            #    \"account\":{\n            #       \"id\":\"109310334138718878\",\n            #       \"username\":\"caronc\",\n            #       \"acct\":\"caronc\",\n            #       \"display_name\":\"Chris\",\n            #       \"locked\":false,\n            #       \"bot\":false,\n            #       \"discoverable\":false,\n            #       \"group\":false,\n            #       \"created_at\":\"2022-11-08T00:00:00.000Z\",\n            #       \"note\":\"content\",\n            #       \"url\":\"https://host/@caronc\",\n            #       \"avatar\":\"https://host/path/file.png\",\n            #       \"avatar_static\":\"https://host/path/file.png\",\n            #       \"header\":\"https://host/headers/original/missing.png\",\n            #       \"header_static\":\"https://host/path/missing.png\",\n            #       \"followers_count\":0,\n            #       \"following_count\":0,\n            #       \"statuses_count\":15,\n            #       \"last_status_at\":\"2022-11-09\",\n            #       \"emojis\":[\n            #\n            #       ],\n            #       \"fields\":[\n            #\n            #       ]\n            #    },\n            #    \"media_attachments\":[\n            #       {\n            #          \"id\":\"109315796405707501\",\n            #          \"type\":\"image\",\n            #          \"url\":\"https://host/path/file.jpeg\",\n            #          \"preview_url\":\"https://host/path/file.jpeg\",\n            #          \"remote_url\":null,\n            #          \"preview_remote_url\":null,\n            #          \"text_url\":null,\n            #          \"meta\":{\n            #             \"original\":{\n            #                \"width\":640,\n            #                \"height\":640,\n            #                \"size\":\"640x640\",\n            #                \"aspect\":1.0\n            #             },\n            #             \"small\":{\n            #                \"width\":400,\n            #                \"height\":400,\n            #                \"size\":\"400x400\",\n            #                \"aspect\":1.0\n            #             }\n            #          },\n            #          \"description\":null,\n            #          \"blurhash\":\"UmIsdJnT^mX4V@XQofnQ~Ebq%4o3ofnQjZbt\"\n            #       }\n            #    ],\n            #    \"mentions\":[\n            #\n            #    ],\n            #    \"tags\":[\n            #\n            #    ],\n            #    \"emojis\":[\n            #\n            #    ],\n            #    \"card\":null,\n            #    \"poll\":null\n            # }\n\n            try:\n                url = \"{}/web/@{}\".format(\n                    self.api_url, response[\"account\"][\"username\"]\n                )\n\n            except (KeyError, TypeError):\n                url = \"unknown\"\n\n            self.logger.debug(\n                \"Mastodon [%.2d/%.2d] (%d attached) delivered to %s\",\n                no,\n                len(payloads),\n                len(payload.get(\"media_ids\", [])),\n                url,\n            )\n\n            self.logger.info(\n                \"Sent [%.2d/%.2d] Mastodon notification as public toot.\",\n                no,\n                len(payloads),\n            )\n\n        return not has_error\n\n    def _whoami(self, lazy=True):\n        \"\"\"Looks details of current authenticated user.\"\"\"\n\n        if lazy and self._whoami_cache is not None:\n            # Use cached response\n            return self._whoami_cache\n\n        # Send Mastodon Whoami request\n        postokay, response = self._request(\n            self.mastodon_whoami,\n            method=\"GET\",\n        )\n\n        if postokay:\n            # Sample Response:\n            # {\n            #   'id': '12345',\n            #   'username': 'caronc',\n            #   'acct': 'caronc',\n            #   'display_name': 'Chris',\n            #   'locked': False,\n            #   'bot': False,\n            #   'discoverable': False,\n            #   'group': False,\n            #   'created_at': '2022-11-08T00:00:00.000Z',\n            #   'note': 'details',\n            #   'url': 'https://noc.social/@caronc',\n            #   'avatar': 'https://host/path/image.png',\n            #   'avatar_static': 'https://host/path/image.png',\n            #   'header': 'https://host/path/missing.png',\n            #   'header_static': 'https://host/path/missing.png',\n            #   'followers_count': 0,\n            #   'following_count': 0,\n            #   'statuses_count': 2,\n            #   'last_status_at': '2022-11-09',\n            #   'source': {\n            #     'privacy': 'public',\n            #     'sensitive': False,\n            #     'language': None,\n            #     'note': 'details',\n            #     'fields': [],\n            #     'follow_requests_count': 0\n            #   },\n            #   'emojis': [],\n            #   'fields': []\n            # }\n            with contextlib.suppress(TypeError, KeyError):\n                # Cache our response for future references\n                self._whoami_cache = {response[\"username\"]: response[\"id\"]}\n\n        elif response and \"authorized scopes\" in response.get(\"error\", \"\"):\n            self.logger.warning(\n                \"Failed to lookup Mastodon Auth details; \"\n                \"missing scope: read:accounts\"\n            )\n\n        return self._whoami_cache if postokay else {}\n\n    def _request(self, path, payload=None, method=\"POST\"):\n        \"\"\"Wrapper to Mastodon API requests object.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Authorization\": f\"Bearer {self.token}\",\n        }\n\n        data = None\n        files = None\n\n        # Prepare our message\n        url = f\"{self.api_url}{path}\"\n\n        # Some Debug Logging\n        self.logger.debug(\n            f\"Mastodon {method} URL:\"\n            f\" {url} (cert_verify={self.verify_certificate})\"\n        )\n\n        # Open our attachment path if required:\n        if isinstance(payload, AttachBase):\n            # prepare payload\n            files = {\n                \"file\": (\n                    payload.name,\n                    # file handle is safely closed in `finally`; inline open\n                    # is intentional\n                    open(payload.path, \"rb\"),  # noqa: SIM115\n                    \"application/octet-stream\",\n                )\n            }\n\n            # Provide a description\n            data = {\n                \"description\": payload.name,\n            }\n\n        else:\n            headers[\"Content-Type\"] = \"application/json\"\n            data = dumps(payload)\n            self.logger.debug(f\"Mastodon Payload: {payload!s}\")\n\n        # Default content response object\n        content = {}\n\n        # By default set wait to None\n        wait = None\n\n        if self.ratelimit_remaining == 0:\n            # Determine how long we should wait for or if we should wait at\n            # all. This isn't fool-proof because we can't be sure the client\n            # time (calling this script) is completely synced up with the\n            # Mastodon server.  One would hope we're on NTP and our clocks are\n            # the same allowing this to role smoothly:\n\n            now = datetime.now(timezone.utc).replace(tzinfo=None)\n            if now < self.ratelimit_reset:\n                # We need to throttle for the difference in seconds\n                # We add 0.5 seconds to the end just to allow a grace\n                # period.\n                wait = (self.ratelimit_reset - now).total_seconds() + 0.5\n\n        # Always call throttle before any remote server i/o is made;\n        self.throttle(wait=wait)\n\n        # acquire our request mode\n        fn = requests.post if method == \"POST\" else requests.get\n\n        try:\n            r = fn(\n                url,\n                data=data,\n                files=files,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                content = loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                content = {}\n\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.created,\n                requests.codes.accepted,\n            ):\n\n                # We had a problem\n                status_str = NotifyMastodon.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Mastodon {} to {}: {}error={}.\".format(\n                        method, url, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                return (False, content)\n\n            try:\n                # Capture rate limiting if possible\n                self.ratelimit_remaining = int(\n                    r.headers.get(\"X-RateLimit-Remaining\")\n                )\n                self.ratelimit_reset = datetime.fromtimestamp(\n                    int(r.headers.get(\"X-RateLimit-Limit\")), timezone.utc\n                ).replace(tzinfo=None)\n\n            except (TypeError, ValueError):\n                # This is returned if we could not retrieve this information\n                # gracefully accept this state and move on\n                pass\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"Exception received when sending Mastodon {method} to {url}: \"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            return (False, content)\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while handling {}.\".format(\n                    payload.name\n                    if isinstance(payload, AttachBase)\n                    else payload\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return (False, content)\n\n        finally:\n            # Close our file (if it's open) stored in the second element\n            # of our files tuple (index 1)\n            if files:\n                files[\"file\"][1].close()\n\n        return (True, content)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyMastodon.unquote(results[\"qsd\"][\"token\"])\n\n        elif not results[\"password\"] and results[\"user\"]:\n            results[\"token\"] = NotifyMastodon.unquote(results[\"user\"])\n\n        # Apply our targets\n        results[\"targets\"] = NotifyMastodon.split_path(results[\"fullpath\"])\n\n        # The defined Mastodon visibility\n        if \"visibility\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"visibility\"]\n        ):\n            # Simplified version\n            results[\"visibility\"] = NotifyMastodon.unquote(\n                results[\"qsd\"][\"visibility\"]\n            )\n\n        elif results[\"schema\"].startswith(\"toot\"):\n            results[\"visibility\"] = MastodonMessageVisibility.PUBLIC\n\n        # Get Idempotency Key (if specified)\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            results[\"key\"] = NotifyMastodon.unquote(results[\"qsd\"][\"key\"])\n\n        # Get Spoiler Text\n        if \"spoiler\" in results[\"qsd\"] and len(results[\"qsd\"][\"spoiler\"]):\n            results[\"spoiler\"] = NotifyMastodon.unquote(\n                results[\"qsd\"][\"spoiler\"]\n            )\n\n        # Get Language (if specified)\n        if \"language\" in results[\"qsd\"] and len(results[\"qsd\"][\"language\"]):\n            results[\"language\"] = NotifyMastodon.unquote(\n                results[\"qsd\"][\"language\"]\n            )\n\n        # Get Sensitive Flag (for Attachments)\n        results[\"sensitive\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"sensitive\",\n                NotifyMastodon.template_args[\"sensitive\"][\"default\"],\n            )\n        )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyMastodon.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyMastodon.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/matrix.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Great sources\n# - https://github.com/matrix-org/matrix-python-sdk\n# - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst\n#\nfrom json import dumps, loads\nimport re\nfrom time import time\nimport uuid\n\nfrom markdown import markdown\nimport requests\n\nfrom ..common import (\n    NotifyFormat,\n    NotifyImageSize,\n    NotifyType,\n    PersistentStoreMode,\n)\nfrom ..exception import AppriseException\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_hostname, parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Define default path\nMATRIX_V1_WEBHOOK_PATH = \"/api/v1/matrix/hook\"\nMATRIX_V2_API_PATH = \"/_matrix/client/r0\"\nMATRIX_V3_API_PATH = \"/_matrix/client/v3\"\nMATRIX_V3_MEDIA_PATH = \"/_matrix/media/v3\"\nMATRIX_V2_MEDIA_PATH = \"/_matrix/media/r0\"\n\n\nclass MatrixDiscoveryException(AppriseException):\n    \"\"\"Apprise Matrix Exception Class.\"\"\"\n\n\n# Extend HTTP Error Messages\nMATRIX_HTTP_ERROR_MAP = {\n    403: \"Unauthorized - Invalid Token.\",\n    429: \"Rate limit imposed; wait 2s and try again\",\n}\n\n# Matrix Room Syntax\nIS_ROOM_ALIAS = re.compile(\n    r\"^\\s*(#|%23)?(?P<room>[A-Za-z0-9._=-]+)((:|%3A)\"\n    r\"(?P<home_server>[A-Za-z0-9.-]+))?\\s*$\",\n    re.I,\n)\n\n# Room ID MUST start with an exclamation to avoid ambiguity\nIS_ROOM_ID = re.compile(\n    r\"^\\s*(!|&#33;|%21)(?P<room>[A-Za-z0-9._=-]+)((:|%3A)\"\n    r\"(?P<home_server>[A-Za-z0-9.-]+))?\\s*$\",\n    re.I,\n)\n\n\n# Matrix is_image check\nIS_IMAGE = re.compile(r\"^image/.*\", re.I)\n\n\nclass MatrixMessageType:\n    \"\"\"The Matrix Message types.\"\"\"\n\n    TEXT = \"text\"\n    NOTICE = \"notice\"\n\n\n# matrix message types are placed into this list for validation purposes\nMATRIX_MESSAGE_TYPES = (\n    MatrixMessageType.TEXT,\n    MatrixMessageType.NOTICE,\n)\n\n\nclass MatrixVersion:\n    # Version 2\n    V2 = \"2\"\n\n    # Version 3\n    V3 = \"3\"\n\n\n# webhook modes are placed into this list for validation purposes\nMATRIX_VERSIONS = (\n    MatrixVersion.V2,\n    MatrixVersion.V3,\n)\n\n\nclass MatrixWebhookMode:\n    # Webhook Mode is disabled\n    DISABLED = \"off\"\n\n    # The default webhook mode is to just be set to Matrix\n    MATRIX = \"matrix\"\n\n    # Support the slack webhook plugin\n    SLACK = \"slack\"\n\n    # Support the t2bot webhook plugin\n    T2BOT = \"t2bot\"\n\n\n# webhook modes are placed into this list for validation purposes\nMATRIX_WEBHOOK_MODES = (\n    MatrixWebhookMode.DISABLED,\n    MatrixWebhookMode.MATRIX,\n    MatrixWebhookMode.SLACK,\n    MatrixWebhookMode.T2BOT,\n)\n\n\nclass NotifyMatrix(NotifyBase):\n    \"\"\"A wrapper for Matrix Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Matrix\"\n\n    # The services URL\n    service_url = \"https://matrix.org/\"\n\n    # The default protocol\n    protocol = \"matrix\"\n\n    # The default secure protocol\n    secure_protocol = \"matrixs\"\n\n    # Support Attachments\n    attachment_support = True\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/matrix/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_32\n\n    # The maximum allowable characters allowed in the body per message\n    # https://spec.matrix.org/v1.6/client-server-api/#size-limits\n    # The complete event MUST NOT be larger than 65536 bytes, when formatted\n    # with the federation event format, including any signatures, and encoded\n    # as Canonical JSON.\n    #\n    # To gracefully allow for some overhead' we'll define a max body length\n    # of just slighty lower then the limit of the full message itself.\n    body_maxlen = 65000\n\n    # Throttle a wee-bit to avoid thrashing\n    request_rate_per_sec = 0.5\n\n    # How many retry attempts we'll make in the event the server asks us to\n    # throttle back.\n    default_retries = 2\n\n    # The number of micro seconds to wait if we get a 429 error code and\n    # the server doesn't remind us how long we should wait for\n    default_wait_ms = 1000\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference\n    storage_mode = PersistentStoreMode.AUTO\n\n    # Keep our cache for 20 days\n    default_cache_expiry_sec = 60 * 60 * 24 * 20\n\n    # Used for server discovery\n    discovery_base_key = \"__discovery_base\"\n    discovery_identity_key = \"__discovery_identity\"\n\n    # Defines how long we cache our discovery for\n    discovery_cache_length_sec = 86400\n\n    # Define object templates\n    templates = (\n        # Targets are ignored when using t2bot mode; only a token is required\n        \"{schema}://{token}\",\n        \"{schema}://{user}@{token}\",\n        # Matrix Server\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n        \"{schema}://{token}@{host}/{targets}\",\n        \"{schema}://{token}@{host}:{port}/{targets}\",\n        # Webhook mode\n        \"{schema}://{user}:{token}@{host}/{targets}\",\n        \"{schema}://{user}:{token}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"private\": True,\n                \"map_to\": \"password\",\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"target_room_id\": {\n                \"name\": _(\"Target Room ID\"),\n                \"type\": \"string\",\n                \"prefix\": \"!\",\n                \"map_to\": \"targets\",\n            },\n            \"target_room_alias\": {\n                \"name\": _(\"Target Room Alias\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            \"discovery\": {\n                \"name\": _(\"Server Discovery\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"hsreq\": {\n                \"name\": _(\"Force Home Server on Room IDs\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"mode\": {\n                \"name\": _(\"Webhook Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": MATRIX_WEBHOOK_MODES,\n                \"default\": MatrixWebhookMode.DISABLED,\n            },\n            \"version\": {\n                \"name\": _(\"Matrix API Verion\"),\n                \"type\": \"choice:string\",\n                \"values\": MATRIX_VERSIONS,\n                \"default\": MatrixVersion.V3,\n            },\n            \"msgtype\": {\n                \"name\": _(\"Message Type\"),\n                \"type\": \"choice:string\",\n                \"values\": MATRIX_MESSAGE_TYPES,\n                \"default\": MatrixMessageType.TEXT,\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        targets=None,\n        mode=None,\n        msgtype=None,\n        version=None,\n        include_image=None,\n        discovery=None,\n        hsreq=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Matrix Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare a list of rooms to connect and notify\n        self.rooms = parse_list(targets)\n\n        # our home server gets populated after a login/registration\n        self.home_server = None\n\n        # our user_id gets populated after a login/registration\n        self.user_id = None\n\n        # This gets initialized after a login/registration\n        self.access_token = None\n\n        # This gets incremented for each request made against the v3 API\n        self.transaction_id = 0\n\n        # Place an image inline with the message body\n        self.include_image = (\n            self.template_args[\"image\"][\"default\"]\n            if include_image is None\n            else include_image\n        )\n\n        # Prepare Delegate Server Lookup Check\n        self.discovery = (\n            self.template_args[\"discovery\"][\"default\"]\n            if discovery is None\n            else discovery\n        )\n\n        # When enabled, room IDs missing a ':homeserver' segment will\n        # be treated as legacy identifiers and automatically suffixed\n        # with the authenticated homeserver.\n        self.hsreq = (\n            self.template_args[\"hsreq\"][\"default\"]\n            if hsreq is None\n            else hsreq\n        )\n\n        # Setup our mode\n        self.mode = (\n            self.template_args[\"mode\"][\"default\"]\n            if not isinstance(mode, str)\n            else mode.lower()\n        )\n        if self.mode and self.mode not in MATRIX_WEBHOOK_MODES:\n            msg = f\"The mode specified ({mode}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Setup our version\n        self.version = (\n            self.template_args[\"version\"][\"default\"]\n            if not isinstance(version, str)\n            else version\n        )\n        if self.version not in MATRIX_VERSIONS:\n            msg = f\"The version specified ({version}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Setup our message type\n        self.msgtype = (\n            self.template_args[\"msgtype\"][\"default\"]\n            if not isinstance(msgtype, str)\n            else msgtype.lower()\n        )\n        if self.msgtype and self.msgtype not in MATRIX_MESSAGE_TYPES:\n            msg = f\"The msgtype specified ({msgtype}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if self.mode == MatrixWebhookMode.T2BOT:\n            # t2bot configuration requires that a webhook id is specified\n            self.access_token = validate_regex(\n                self.password, r\"^[a-z0-9]{64}$\", \"i\"\n            )\n            if not self.access_token:\n                msg = (\n                    \"An invalid T2Bot/Matrix Webhook ID \"\n                    f\"({self.password}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        elif not is_hostname(self.host):\n            msg = f\"An invalid Matrix Hostname ({self.host}) was specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        else:\n            # Verify port if specified\n            if self.port is not None and not (\n                isinstance(self.port, int)\n                and self.port >= self.template_tokens[\"port\"][\"min\"]\n                and self.port <= self.template_tokens[\"port\"][\"max\"]\n            ):\n                msg = f\"An invalid Matrix Port ({self.port}) was specified\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        if self.mode != MatrixWebhookMode.DISABLED:\n            # Discovery only works when we're not using webhooks\n            self.discovery = False\n\n        #\n        # Initialize from cache if present\n        #\n        if self.mode != MatrixWebhookMode.T2BOT:\n            # our home server gets populated after a login/registration\n            self.home_server = self.store.get(\"home_server\")\n\n            # our user_id gets populated after a login/registration\n            self.user_id = self.store.get(\"user_id\")\n\n            # This gets initialized after a login/registration\n            self.access_token = self.store.get(\"access_token\")\n\n        # This gets incremented for each request made against the v3 API\n        self.transaction_id = (\n            0 if not self.access_token else self.store.get(\"transaction_id\", 0)\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Matrix Notification.\"\"\"\n\n        # Call the _send_ function applicable to whatever mode we're in\n        # - calls _send_webhook_notification if the mode variable is set\n        # - calls _send_server_notification if the mode variable is not set\n        return getattr(\n            self,\n            \"_send_{}_notification\".format(\n                \"webhook\"\n                if self.mode != MatrixWebhookMode.DISABLED\n                else \"server\"\n            ),\n        )(body=body, title=title, notify_type=notify_type, **kwargs)\n\n    def _send_webhook_notification(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"Perform Matrix Notification as a webhook.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        if self.mode != MatrixWebhookMode.T2BOT:\n            # Acquire our access token from our URL\n            access_token = self.password if self.password else self.user\n\n            # Prepare our URL\n            url = \"{schema}://{hostname}{port}{webhook_path}/{token}\".format(\n                schema=\"https\" if self.secure else \"http\",\n                hostname=self.host,\n                port=(\"\" if not self.port else f\":{self.port}\"),\n                webhook_path=MATRIX_V1_WEBHOOK_PATH,\n                token=access_token,\n            )\n\n        else:\n            #\n            # t2bot Setup\n            #\n\n            # Prepare our URL\n            url = (\n                \"https://webhooks.t2bot.io/api/v1/matrix/hook/\"\n                f\"{self.access_token}\"\n            )\n\n        # Retrieve our payload\n        payload = getattr(self, f\"_{self.mode}_webhook_payload\")(\n            body=body, title=title, notify_type=notify_type, **kwargs\n        )\n\n        self.logger.debug(\n            f\"Matrix POST URL: {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Matrix Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyMatrix.http_response_code_lookup(\n                    r.status_code, MATRIX_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send Matrix notification: {}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Matrix notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Matrix notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            # Return; we're done\n            return False\n\n        return True\n\n    def _slack_webhook_payload(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"Format the payload for a Slack based message.\"\"\"\n\n        if not hasattr(self, \"_re_slack_formatting_rules\"):\n            # Prepare some one-time slack formatting variables\n\n            self._re_slack_formatting_map = {\n                # New lines must become the string version\n                r\"\\r\\*\\n\": \"\\\\n\",\n                # Escape other special characters\n                r\"&\": \"&amp;\",\n                r\"<\": \"&lt;\",\n                r\">\": \"&gt;\",\n            }\n\n            # Iterate over above list and store content accordingly\n            self._re_slack_formatting_rules = re.compile(\n                r\"(\" + \"|\".join(self._re_slack_formatting_map.keys()) + r\")\",\n                re.IGNORECASE,\n            )\n\n        # Perform Formatting\n        title = self._re_slack_formatting_rules.sub(  # pragma: no branch\n            lambda x: self._re_slack_formatting_map[x.group()],\n            title,\n        )\n\n        body = self._re_slack_formatting_rules.sub(  # pragma: no branch\n            lambda x: self._re_slack_formatting_map[x.group()],\n            body,\n        )\n\n        # prepare JSON Object\n        payload = {\n            \"username\": self.user if self.user else self.app_id,\n            # Use Markdown language\n            \"mrkdwn\": self.notify_format == NotifyFormat.MARKDOWN,\n            \"attachments\": [{\n                \"title\": title,\n                \"text\": body,\n                \"color\": self.color(notify_type),\n                \"ts\": time(),\n                \"footer\": self.app_id,\n            }],\n        }\n\n        return payload\n\n    def _matrix_webhook_payload(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"Format the payload for a Matrix based message.\"\"\"\n\n        payload = {\n            \"displayName\": self.user if self.user else self.app_id,\n            \"format\": (\n                \"plain\" if self.notify_format == NotifyFormat.TEXT else \"html\"\n            ),\n            \"text\": \"\",\n        }\n\n        if self.notify_format == NotifyFormat.HTML:\n            payload[\"text\"] = \"{title}{body}\".format(\n                title=(\n                    \"\"\n                    if not title\n                    else f\"<h1>{NotifyMatrix.escape_html(title)}</h1>\"\n                ),\n                body=body,\n            )\n\n        elif self.notify_format == NotifyFormat.MARKDOWN:\n            payload[\"text\"] = \"{title}{body}\".format(\n                title=(\n                    \"\"\n                    if not title\n                    else f\"<h1>{NotifyMatrix.escape_html(title)}</h1>\"\n                ),\n                body=markdown(body),\n            )\n\n        else:  # NotifyFormat.TEXT\n            payload[\"text\"] = body if not title else f\"{title}\\r\\n{body}\"\n\n        return payload\n\n    def _t2bot_webhook_payload(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"Format the payload for a T2Bot Matrix based messages.\"\"\"\n\n        # Retrieve our payload\n        payload = self._matrix_webhook_payload(\n            body=body, title=title, notify_type=notify_type, **kwargs\n        )\n\n        # Acquire our image url if we're configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        if image_url:\n            # t2bot can take an avatarUrl Entry\n            payload[\"avatarUrl\"] = image_url\n\n        return payload\n\n    def _send_server_notification(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Direct Matrix Server Notification (no webhook)\"\"\"\n\n        if self.access_token is None and self.password and not self.user:\n            self.access_token = self.password\n            self.transaction_id = uuid.uuid4()\n\n        if self.access_token is None and not self._login() \\\n                and not self._register():\n            # We need to register\n            return False\n\n        if len(self.rooms) == 0:\n            # Attempt to retrieve a list of already joined channels\n            self.rooms = self._joined_rooms()\n\n            if len(self.rooms) == 0:\n                # Nothing to notify\n                self.logger.warning(\n                    \"There were no Matrix rooms specified to notify.\"\n                )\n                return False\n\n        # Create a copy of our rooms to join and message\n        rooms = list(self.rooms)\n\n        # Initiaize our error tracking\n        has_error = False\n\n        attachments = None\n        if attach and self.attachment_support:\n            attachments = self._send_attachments(attach)\n            if attachments is False:\n                # take an early exit\n                return False\n\n        while len(rooms) > 0:\n\n            # Get our room\n            room = rooms.pop(0)\n\n            # Set method according to MatrixVersion\n            method = \"PUT\" if self.version == MatrixVersion.V3 else \"POST\"\n\n            # Get our room_id from our response\n            room_id = self._room_join(room)\n            if not room_id:\n                # Notify our user about our failure\n                self.logger.warning(f\"Could not join Matrix room {room}.\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n            # Acquire our image url if we're configured to do so\n            image_url = (\n                None if not self.include_image else self.image_url(notify_type)\n            )\n\n            # Build our path\n            if self.version == MatrixVersion.V3:\n                path = f\"/rooms/{NotifyMatrix.quote(room_id)}\" \\\n                    f\"/send/m.room.message/{self.transaction_id}\"\n\n            else:\n                path = (\n                    f\"/rooms/{NotifyMatrix.quote(room_id)}/send/m.room.message\"\n                )\n\n            if image_url and self.version == MatrixVersion.V2:\n                # Define our payload\n                image_payload = {\n                    \"msgtype\": \"m.image\",\n                    \"url\": image_url,\n                    \"body\": f\"{title if title else notify_type}\",\n                }\n\n                # Post our content\n                postokay, _, _ = self._fetch(\n                    path, payload=image_payload)\n                if not postokay:\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n            if attachments:\n                for attachment in attachments:\n                    attachment[\"room_id\"] = room_id\n                    attachment[\"type\"] = \"m.room.message\"\n\n                    postokay, _, _ = self._fetch(\n                        path, payload=attachment, method=method)\n\n                    # Increment the transaction ID to avoid future messages\n                    # being recognized as retransmissions and ignored\n                    if self.version == MatrixVersion.V3 \\\n                       and self.access_token != self.password:\n                        self.transaction_id += 1\n                        self.store.set(\n                            \"transaction_id\", self.transaction_id,\n                            expires=self.default_cache_expiry_sec)\n                        path = \"/rooms/{}/send/m.room.message/{}\".format(\n                            NotifyMatrix.quote(room_id),\n                            self.transaction_id,\n                        )\n\n                    if not postokay:\n                        # Mark our failure\n                        has_error = True\n                        continue\n\n            # Define our payload\n            payload = {\n                \"msgtype\": f\"m.{self.msgtype}\",\n                \"body\": \"{title}{body}\".format(\n                    title=\"\" if not title else f\"# {title}\\r\\n\", body=body\n                ),\n            }\n\n            # Update our payload advance formatting for the services that\n            # support them.\n            if self.notify_format == NotifyFormat.HTML:\n                payload.update({\n                    \"format\": \"org.matrix.custom.html\",\n                    \"formatted_body\": \"{title}{body}\".format(\n                        title=\"\" if not title else f\"<h1>{title}</h1>\",\n                        body=body,\n                    ),\n                })\n\n            elif self.notify_format == NotifyFormat.MARKDOWN:\n                title_ = (\n                    \"\"\n                    if not title\n                    else (\n                        \"<h1>\"\n                        f\"{NotifyMatrix.escape_html(title, whitespace=False)}\"\n                        \"</h1>\"\n                    )\n                )\n\n                payload.update({\n                    \"format\": \"org.matrix.custom.html\",\n                    \"formatted_body\": \"{title}{body}\".format(\n                        title=title_,\n                        body=markdown(body),\n                    ),\n                })\n\n            # Post our content\n            postokay, _, _ = self._fetch(\n                path, payload=payload, method=method\n            )\n\n            # Increment the transaction ID to avoid future messages being\n            # recognized as retransmissions and ignored\n            if (\n                self.version == MatrixVersion.V3\n                and self.access_token != self.password\n            ):\n                self.transaction_id += 1\n                self.store.set(\n                    \"transaction_id\",\n                    self.transaction_id,\n                    expires=self.default_cache_expiry_sec,\n                )\n\n            if not postokay:\n                # Notify our user\n                self.logger.warning(\n                    f\"Could not send notification Matrix room {room}.\"\n                )\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    def _send_attachments(self, attach):\n        \"\"\"Posts all of the provided attachments.\"\"\"\n\n        payloads = []\n\n        for attachment in attach:\n            if not attachment:\n                # invalid attachment (bad file)\n                return False\n\n            if not IS_IMAGE.match(attachment.mimetype) \\\n               and self.version == MatrixVersion.V2:\n                # unsuppored at this time\n                continue\n\n            postokay, response, _ = \\\n                self._fetch(\"/upload\", attachment=attachment)\n            if not (postokay and isinstance(response, dict)):\n                # Failed to perform upload\n                return False\n\n            # If we get here, we'll have a response that looks like:\n            # {\n            #     \"content_uri\": \"mxc://example.com/a-unique-key\"\n            # }\n\n            if self.version == MatrixVersion.V3:\n                # Prepare our payload\n                is_image = IS_IMAGE.match(attachment.mimetype)\n                payloads.append({\n                    \"body\": attachment.name,\n                    \"info\": {\n                        \"mimetype\": attachment.mimetype,\n                        \"size\": len(attachment),\n                    },\n                    \"msgtype\": \"m.image\" if is_image else \"m.file\",\n                    \"url\": response.get(\"content_uri\"),\n                })\n                if not is_image:\n                    # Setup `m.file'\n                    payloads[-1][\"filename\"] = attachment.name\n\n            else:\n                # Prepare our payload\n                payloads.append({\n                    \"info\": {\n                        \"mimetype\": attachment.mimetype,\n                    },\n                    \"msgtype\": \"m.image\",\n                    \"body\": \"tta.webp\",\n                    \"url\": response.get(\"content_uri\"),\n                })\n\n        return payloads\n\n    def _register(self):\n        \"\"\"Register with the service if possible.\"\"\"\n\n        # Prepare our Registration Payload. This will only work if registration\n        # is enabled for the public\n        payload = {\n            \"kind\": \"user\",\n            \"auth\": {\"type\": \"m.login.dummy\"},\n        }\n\n        # parameters\n        params = {\n            \"kind\": \"user\",\n        }\n\n        # If a user is not specified, one will be randomly generated for you.\n        # If you do not specify a password, you will be unable to login to the\n        # account if you forget the access_token.\n        if self.user:\n            payload[\"username\"] = self.user\n\n        if self.password:\n            payload[\"password\"] = self.password\n\n        # Register\n        postokay, response, _ = self._fetch(\n            \"/register\", payload=payload, params=params\n        )\n        if not (postokay and isinstance(response, dict)):\n            # Failed to register\n            return False\n\n        # Pull the response details\n        self.access_token = response.get(\"access_token\")\n        self.home_server = response.get(\"home_server\")\n        self.user_id = response.get(\"user_id\")\n\n        self.store.set(\n            \"access_token\",\n            self.access_token,\n            expires=self.default_cache_expiry_sec,\n        )\n        self.store.set(\n            \"home_server\",\n            self.home_server,\n            expires=self.default_cache_expiry_sec,\n        )\n        self.store.set(\n            \"user_id\", self.user_id, expires=self.default_cache_expiry_sec\n        )\n\n        if self.access_token is not None:\n            # Store our token into our store\n            self.logger.debug(\"Registered successfully with Matrix server.\")\n            return True\n\n        return False\n\n    def _login(self):\n        \"\"\"Acquires the matrix token required for making future requests.\n\n        If we fail we return False, otherwise we return True\n        \"\"\"\n\n        if self.access_token:\n            # Login not required; silently skip-over\n            return True\n\n        if self.user and self.password:\n            # Prepare our Authentication Payload\n            if self.version == MatrixVersion.V3:\n                payload = {\n                    \"type\": \"m.login.password\",\n                    \"identifier\": {\n                        \"type\": \"m.id.user\",\n                        \"user\": self.user,\n                    },\n                    \"password\": self.password,\n                }\n\n            else:\n                payload = {\n                    \"type\": \"m.login.password\",\n                    \"user\": self.user,\n                    \"password\": self.password,\n                }\n\n        else:\n            # It's not possible to register since we need these 2 values to\n            # make the action possible.\n            self.logger.warning(\n                \"Failed to login to Matrix server: \"\n                \"token or user/pass combo is missing.\"\n            )\n            return False\n\n        # Build our URL\n        postokay, response, _ = self._fetch(\"/login\", payload=payload)\n        if not (postokay and isinstance(response, dict)):\n            # Failed to login\n            return False\n\n        # Pull the response details\n        self.access_token = response.get(\"access_token\")\n        self.home_server = response.get(\"home_server\")\n        self.user_id = response.get(\"user_id\")\n\n        if not self.access_token:\n            return False\n\n        self.logger.debug(\"Authenticated successfully with Matrix server.\")\n\n        # Store our token into our store\n        self.store.set(\n            \"access_token\",\n            self.access_token,\n            expires=self.default_cache_expiry_sec,\n        )\n        self.store.set(\n            \"home_server\",\n            self.home_server,\n            expires=self.default_cache_expiry_sec,\n        )\n        self.store.set(\n            \"user_id\", self.user_id, expires=self.default_cache_expiry_sec\n        )\n\n        return True\n\n    def _logout(self):\n        \"\"\"Relinquishes token from remote server.\"\"\"\n\n        if not self.access_token:\n            # Login not required; silently skip-over\n            return True\n\n        # Prepare our Registration Payload\n        payload = {}\n\n        # Expire our token\n        postokay, response, _ = self._fetch(\"/logout\", payload=payload)\n        if not postokay and response.get(\"errcode\") != \"M_UNKNOWN_TOKEN\":\n            # If we get here, the token was declared as having already\n            # been expired.  The response looks like this:\n            # {\n            #    u'errcode': u'M_UNKNOWN_TOKEN',\n            #    u'error': u'Access Token unknown or expired',\n            # }\n            #\n            # In this case it's okay to safely return True because\n            # we're logged out in this case.\n            return False\n\n        # else: The response object looks like this if we were successful:\n        #  {}\n\n        # Pull the response details\n        self.access_token = None\n        self.home_server = None\n        self.user_id = None\n\n        # clear our tokens\n        self.store.clear(\n            \"access_token\", \"home_server\", \"user_id\", \"transaction_id\"\n        )\n\n        self.logger.debug(\"Unauthenticated successfully with Matrix server.\")\n\n        return True\n\n    def _room_join(self, room):\n        \"\"\"Joins a matrix room if we're not already in it.\n\n        Otherwise it attempts to create it if it doesn't exist and always\n        returns the room_id if it was successful, otherwise it returns None\n        \"\"\"\n\n        if not self.access_token:\n            # We can't join a room if we're not logged in\n            return None\n\n        if not isinstance(room, str):\n            # Not a supported string\n            return None\n\n        # Prepare our Join Payload\n        payload = {}\n\n        # Check if it's a room id...\n        result = IS_ROOM_ID.match(room)\n        if result:\n            room_token = result.group(\"room\")\n            explicit_home_server = result.group(\"home_server\")\n\n            # Determine the homeserver context (used for cache metadata)\n            home_server = (\n                explicit_home_server\n                if explicit_home_server else self.home_server\n            )\n\n            # When hsreq is enabled (legacy behaviour), we always require a\n            # ':homeserver' segment on room IDs. Otherwise, we honour exactly\n            # what the caller provided and do not synthesise a homeserver when\n            # it was not specified.\n            cache_key = f\"!{room_token}:{home_server}\"\n            if explicit_home_server or self.hsreq:\n                room_id = cache_key\n            else:\n                room_id = f\"!{room_token}\"\n\n            # Check our cache for speed:\n            try:\n                return self.store[cache_key][\"id\"]\n\n            except KeyError:\n                pass\n\n            # Build our URL\n            path = f\"/join/{NotifyMatrix.quote(room_id)}\"\n\n            # Attempt to join the channel\n            postokay, response, _status_code = \\\n                self._fetch(path, payload=payload)\n            if not postokay:\n                return None\n\n            # Prefer the server-provided room_id if one was returned,\n            # otherwise fall back to whatever we joined with.\n            joined_id = (\n                response.get(\"room_id\") if isinstance(response, dict) else None\n            ) or room_id\n\n            # Cache mapping for faster future lookups.\n            self.store.set(\n                cache_key,\n                {\n                    \"id\": joined_id,\n                    \"home_server\": home_server,\n                },\n            )\n\n            return joined_id\n\n        # Try to see if it's an alias then...\n        result = IS_ROOM_ALIAS.match(room)\n        if not result:\n            # There is nothing else it could be\n            self.logger.warning(\n                f\"Ignoring illegally formed room {room} \"\n                \"from Matrix server list.\"\n            )\n            return None\n\n        # If we reach here, we're dealing with a channel alias\n        home_server = (\n            self.home_server\n            if not result.group(\"home_server\")\n            else result.group(\"home_server\")\n        )\n\n        # tidy our room (alias) identifier\n        room = \"#{}:{}\".format(result.group(\"room\"), home_server)\n\n        # Check our cache for speed:\n        try:\n            # We're done as we've already joined the channel\n            return self.store[room][\"id\"]\n\n        except KeyError:\n            # No worries, we'll try to acquire the info\n            pass\n\n        # If we reach here, we need to join the channel\n\n        # Build our URL\n        path = f\"/join/{NotifyMatrix.quote(room)}\"\n\n        # Attempt to join the channel\n        postokay, response, status_code = self._fetch(path, payload=payload)\n        if postokay:\n            # Cache our entry for fast access later\n            self.store.set(\n                room,\n                {\n                    \"id\": response.get(\"room_id\"),\n                    \"home_server\": home_server,\n                },\n            )\n\n            return response.get(\"room_id\")\n\n        # Only attempt to create a room when the server clearly indicates\n        # the alias does not exist. A join can fail for many reasons, such as\n        # invite required, auth failure, or permissions, and in those cases\n        # auto-creating is both noisy and incorrect.\n        if (status_code == requests.codes.not_found\n                or response.get(\"errcode\") == \"M_NOT_FOUND\"):\n            return self._room_create(room)\n\n        self.logger.warning(\n            \"Could not join Matrix room alias %s (error=%s). \"\n            \"If this is a private room, ensure the user is invited or \"\n            \"already joined, or specify the room_id (!...).\",\n            room,\n            status_code,\n        )\n        return None\n\n    def _room_create(self, room):\n        \"\"\"Creates a matrix room and return it's room_id if successful\n        otherwise None is returned.\"\"\"\n        if not self.access_token:\n            # We can't create a room if we're not logged in\n            return None\n\n        if not isinstance(room, str):\n            # Not a supported string\n            return None\n\n        # Build our room if we have to:\n        result = IS_ROOM_ALIAS.match(room)\n        if not result:\n            # Illegally formed room\n            return None\n\n        # Our home_server\n        home_server = (\n            result.group(\"home_server\")\n            if result.group(\"home_server\")\n            else self.home_server\n        )\n\n        # update our room details\n        room = \"#{}:{}\".format(result.group(\"room\"), home_server)\n\n        # Prepare our Create Payload\n        payload = {\n            \"room_alias_name\": result.group(\"room\"),\n            # Set our channel name\n            \"name\": \"#{} - {}\".format(result.group(\"room\"), self.app_desc),\n            # hide the room by default; let the user open it up if they wish\n            # to others.\n            \"visibility\": \"private\",\n            \"preset\": \"trusted_private_chat\",\n        }\n\n        postokay, response, _ = \\\n            self._fetch(\"/createRoom\", payload=payload)\n        if not postokay:\n            # Failed to create channel\n            # Typical responses:\n            #   - {u'errcode': u'M_ROOM_IN_USE',\n            #      u'error': u'Room alias already taken'}\n            #   - {u'errcode': u'M_UNKNOWN',\n            #      u'error': u'Internal server error'}\n            if response and response.get(\"errcode\") == \"M_ROOM_IN_USE\":\n                return self._room_id(room)\n            return None\n\n        # Cache our entry for fast access later\n        self.store.set(\n            response.get(\"room_alias\"),\n            {\n                \"id\": response.get(\"room_id\"),\n                \"home_server\": home_server,\n            },\n        )\n\n        return response.get(\"room_id\")\n\n    def _joined_rooms(self):\n        \"\"\"Returns a list of the current rooms the logged in user is a part\n        of.\"\"\"\n\n        if not self.access_token:\n            # No list is possible\n            return []\n\n        postokay, response, _ = self._fetch(\n            \"/joined_rooms\", payload=None, method=\"GET\"\n        )\n        if not postokay:\n            # Failed to retrieve listings\n            return []\n\n        # Return our list of rooms\n        return response.get(\"joined_rooms\", [])\n\n    def _room_id(self, room):\n        \"\"\"Get room id from its alias.\n        Args:\n            room (str): The room alias name.\n\n        Returns:\n            returns the room id if it can, otherwise it returns None\n        \"\"\"\n\n        if not self.access_token:\n            # We can't get a room id if we're not logged in\n            return None\n\n        if not isinstance(room, str):\n            # Not a supported string\n            return None\n\n        # Build our room if we have to:\n        result = IS_ROOM_ALIAS.match(room)\n        if not result:\n            # Illegally formed room\n            return None\n\n        # Our home_server\n        home_server = (\n            result.group(\"home_server\")\n            if result.group(\"home_server\")\n            else self.home_server\n        )\n\n        # update our room details\n        room = \"#{}:{}\".format(result.group(\"room\"), home_server)\n\n        # Make our request\n        postokay, response, _ = self._fetch(\n            f\"/directory/room/{NotifyMatrix.quote(room)}\",\n            payload=None,\n            method=\"GET\",\n        )\n\n        if postokay:\n            return response.get(\"room_id\")\n\n        return None\n\n    def _fetch(\n        self,\n        path,\n        payload=None,\n        params=None,\n        attachment=None,\n        method=\"POST\",\n        url_override=None,\n    ):\n        \"\"\"Wrapper to request.post() to manage it's response better and make\n        the send() function cleaner and easier to maintain.\n\n        This function always returns a 3-tuple:\n            (success, response, status_code)\n\n        The response is a dict when JSON is parseable, otherwise an empty dict.\n        The status_code defaults to 500 on local failures.\n        \"\"\"\n\n        # Define our headers\n        if params is None:\n            params = {}\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n        }\n\n        if self.access_token is not None:\n            headers[\"Authorization\"] = f\"Bearer {self.access_token}\"\n\n        # Server Discovery / Well-known URI\n        if url_override:\n            url = url_override\n\n        else:\n            try:\n                url = self.base_url\n\n            except MatrixDiscoveryException:\n                # Discovery failed; we're done\n                return (False, {}, requests.codes.internal_server_error)\n\n        # Default return status code\n        status_code = requests.codes.internal_server_error\n\n        if path == \"/upload\":\n            if self.version == MatrixVersion.V3:\n                url += MATRIX_V3_MEDIA_PATH + path\n\n            else:\n                url += MATRIX_V2_MEDIA_PATH + path\n\n            params.update({\"filename\": attachment.name})\n            with open(attachment.path, \"rb\") as fp:\n                payload = fp.read()\n\n            # Update our content type\n            headers[\"Content-Type\"] = attachment.mimetype\n\n        elif not url_override:\n            if self.version == MatrixVersion.V3:\n                url += MATRIX_V3_API_PATH + path\n\n            else:\n                url += MATRIX_V2_API_PATH + path\n\n        # Our response object\n        response = {}\n\n        # fetch function\n        fn = (\n            requests.post\n            if method == \"POST\"\n            else (requests.put if method == \"PUT\" else requests.get)\n        )\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # Define how many attempts we'll make if we get caught in a throttle\n        # event\n        retries = self.default_retries if self.default_retries > 0 else 1\n        while retries > 0:\n\n            # Decrement our throttle retry count\n            retries -= 1\n\n            self.logger.debug(\n                \"Matrix {} URL: {} (cert_verify={!r})\".format(\n                    (\n                        \"POST\"\n                        if method == \"POST\"\n                        else (\"PUT\" if method == \"PUT\" else \"GET\")\n                    ),\n                    url,\n                    self.verify_certificate,\n                )\n            )\n            self.logger.debug(f\"Matrix Payload: {payload!s}\")\n\n            # Initialize our response object\n            r = None\n\n            try:\n                r = fn(\n                    url,\n                    data=dumps(payload) if not attachment else payload,\n                    params=params if params else None,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # Store status code\n                status_code = r.status_code\n\n                self.logger.debug(\n                    f\"Matrix Response: code={r.status_code}, {r.content!s}\"\n                )\n                response = loads(r.content)\n\n                if r.status_code == requests.codes.too_many_requests:\n                    wait_ms = self.default_wait_ms\n                    try:\n                        wait_ms = response[\"retry_after_ms\"]\n\n                    except KeyError:\n                        try:\n                            errordata = response[\"error\"]\n                            wait_ms = errordata[\"retry_after_ms\"]\n                        except KeyError:\n                            pass\n\n                    self.logger.warning(\n                        \"Matrix server requested we throttle back \"\n                        f\"{wait_ms}ms; retries left {retries}.\"\n                    )\n                    self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                    # Throttle for specified wait\n                    self.throttle(wait=wait_ms / 1000)\n\n                    # Try again\n                    continue\n\n                elif r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyMatrix.http_response_code_lookup(\n                        r.status_code, MATRIX_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to handshake with Matrix server: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                    # Return; we're done\n                    return (False, response, status_code)\n\n            except (AttributeError, TypeError, ValueError):\n                # This gets thrown if we can't parse our JSON Response\n                #  - ValueError = r.content is Unparsable\n                #  - TypeError = r.content is None\n                #  - AttributeError = r is None\n                self.logger.warning(\"Invalid response from Matrix server.\")\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\",\n                    b\"\" if not r else (r.content or b\"\"))\n                return (False, {}, status_code)\n\n            except (requests.TooManyRedirects, requests.RequestException) as e:\n                self.logger.warning(\n                    \"A Connection error occurred while registering with Matrix\"\n                    \" server.\"\n                )\n                self.logger.debug(\"Socket Exception: %s\", e)\n                # Return; we're done\n                return (False, response, status_code)\n\n            except OSError as e:\n                self.logger.warning(\n                    \"An I/O error occurred while reading {}.\".format(\n                        attachment.name if attachment else \"unknown file\"\n                    )\n                )\n                self.logger.debug(\"I/O Exception: %s\", e)\n                return (False, {}, status_code)\n\n            return (True, response, status_code)\n\n        # If we get here, we ran out of retries\n        return (False, {}, status_code)\n\n    def __del__(self):\n        \"\"\"Ensure we relinquish our token.\"\"\"\n        if self.mode == MatrixWebhookMode.T2BOT:\n            # nothing to do\n            return\n\n        if self.store.mode != PersistentStoreMode.MEMORY:\n            # We no longer have to log out as we have persistant storage to\n            # re-use our credentials with\n            return\n\n        if (\n            self.access_token is not None\n            and self.access_token == self.password\n            and not self.user\n        ):\n            return\n\n        self._logout()\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            (\n                self.host\n                if self.mode != MatrixWebhookMode.T2BOT\n                else self.access_token\n            ),\n            self.port if self.port else (443 if self.secure else 80),\n            self.user if self.mode != MatrixWebhookMode.T2BOT else None,\n            self.password if self.mode != MatrixWebhookMode.T2BOT else None,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"mode\": self.mode,\n            \"version\": self.version,\n            \"msgtype\": self.msgtype,\n            \"discovery\": \"yes\" if self.discovery else \"no\",\n            \"hsreq\": \"yes\" if self.hsreq else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        auth = \"\"\n        if self.mode != MatrixWebhookMode.T2BOT:\n            # Determine Authentication\n            if self.user and self.password:\n                auth = \"{user}:{password}@\".format(\n                    user=NotifyMatrix.quote(self.user, safe=\"\"),\n                    password=self.pprint(\n                        self.password,\n                        privacy,\n                        mode=PrivacyMode.Secret,\n                        safe=\"\",\n                    ),\n                )\n\n            elif self.user or self.password:\n                auth = \"{value}@\".format(\n                    value=NotifyMatrix.quote(\n                        self.user if self.user else self.password, safe=\"\"\n                    ),\n                )\n\n        return \"{schema}://{auth}{hostname}{port}/{rooms}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            hostname=(\n                NotifyMatrix.quote(self.host, safe=\"\")\n                if self.mode != MatrixWebhookMode.T2BOT\n                else self.pprint(self.access_token, privacy, safe=\"\")\n            ),\n            port=(\"\" if not self.port else f\":{self.port}\"),\n            rooms=NotifyMatrix.quote(\"/\".join(self.rooms)),\n            params=NotifyMatrix.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.rooms)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if not results.get(\"host\"):\n            return None\n\n        # Get our rooms\n        results[\"targets\"] = NotifyMatrix.split_path(results[\"fullpath\"])\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyMatrix.parse_list(results[\"qsd\"][\"to\"])\n\n        # Boolean to include an image or not\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyMatrix.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # Boolean to perform a server discovery\n        results[\"discovery\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"discovery\", NotifyMatrix.template_args[\"discovery\"][\"default\"]\n            )\n        )\n\n        # Boolean to enforce ':homeserver' on room IDs when missing\n        results[\"hsreq\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"hsreq\", NotifyMatrix.template_args[\"hsreq\"][\"default\"]\n            )\n        )\n\n        # Get our mode\n        results[\"mode\"] = results[\"qsd\"].get(\"mode\")\n\n        # t2bot detection... look for just a hostname, and/or just a user/host\n        # if we match this; we can go ahead and set the mode (but only if\n        # it was otherwise not set)\n        if (\n            results[\"mode\"] is None\n            and not results[\"password\"]\n            and not results[\"targets\"]\n        ):\n\n            # Default mode to t2bot\n            results[\"mode\"] = MatrixWebhookMode.T2BOT\n\n        if (\n            results[\"mode\"]\n            and results[\"mode\"].lower() == MatrixWebhookMode.T2BOT\n        ):\n            # unquote our hostname and pass it in as the password/token\n            results[\"password\"] = NotifyMatrix.unquote(results[\"host\"])\n\n        # Support the message type keyword\n        if \"msgtype\" in results[\"qsd\"] and len(results[\"qsd\"][\"msgtype\"]):\n            results[\"msgtype\"] = NotifyMatrix.unquote(\n                results[\"qsd\"][\"msgtype\"]\n            )\n\n        # Support the use of the token= keyword\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"password\"] = NotifyMatrix.unquote(results[\"qsd\"][\"token\"])\n\n        elif not results[\"password\"] and results[\"user\"]:\n            # swap\n            results[\"password\"] = results[\"user\"]\n            results[\"user\"] = None\n\n        # Support the use of the version= or v= keyword\n        if \"version\" in results[\"qsd\"] and len(results[\"qsd\"][\"version\"]):\n            results[\"version\"] = NotifyMatrix.unquote(\n                results[\"qsd\"][\"version\"]\n            )\n\n        elif \"v\" in results[\"qsd\"] and len(results[\"qsd\"][\"v\"]):\n            results[\"version\"] = NotifyMatrix.unquote(results[\"qsd\"][\"v\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://webhooks.t2bot.io/api/v1/matrix/hook/WEBHOOK_TOKEN/\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://webhooks\\.t2bot\\.io/api/v[0-9]+/matrix/hook/\"\n            r\"(?P<webhook_token>[A-Z0-9_-]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            mode = f\"mode={MatrixWebhookMode.T2BOT}\"\n\n            return NotifyMatrix.parse_url(\n                \"{schema}://{webhook_token}/{params}\".format(\n                    schema=NotifyMatrix.secure_protocol,\n                    webhook_token=result.group(\"webhook_token\"),\n                    params=(\n                        f\"?{mode}\"\n                        if not result.group(\"params\")\n                        else \"{}&{}\".format(result.group(\"params\"), mode)\n                    ),\n                )\n            )\n\n        return None\n\n    def server_discovery(self):\n        \"\"\"\n        Home Server Discovery as documented here:\n           https://spec.matrix.org/v1.11/client-server-api/#well-known-uri\n        \"\"\"\n\n        if not (self.discovery and self.secure):\n            # Nothing further to do with insecure server setups\n            return \"\"\n\n        # Get our content from cache\n        base_url, identity_url = (\n            self.store.get(self.discovery_base_key),\n            self.store.get(self.discovery_identity_key),\n        )\n\n        if not (base_url is None and identity_url is None):\n            # We can use our cached value and return early\n            return base_url\n\n        # the Matrix ID at the first colon.\n        verify_url = \\\n            \"{schema}://{hostname}{port}/.well-known/matrix/client\".format(\n                schema=\"https\" if self.secure else \"http\",\n                hostname=self.host,\n                port=(\"\" if not self.port else f\":{self.port}\"),\n            )\n\n        _, response, status_code = self._fetch(\n            None, method=\"GET\", url_override=verify_url\n        )\n\n        # Output may look as follows:\n        # {\n        #     \"m.homeserver\": {\n        #         \"base_url\": \"https://matrix.example.com\"\n        #     },\n        #     \"m.identity_server\": {\n        #         \"base_url\": \"https://nuxref.com\"\n        #     }\n        # }\n\n        if status_code == requests.codes.not_found:\n            # This is an acceptable response; we're done\n            self.logger.debug(\n                \"Matrix Well-Known Base URI not found at %s\", verify_url\n            )\n\n            # Set our keys out for fast recall later on\n            self.store.set(\n                self.discovery_base_key,\n                \"\",\n                expires=self.discovery_cache_length_sec,\n            )\n            self.store.set(\n                self.discovery_identity_key,\n                \"\",\n                expires=self.discovery_cache_length_sec,\n            )\n            return \"\"\n\n        elif status_code != requests.codes.ok:\n            # We're done early as we couldn't load the results\n            msg = \"Matrix Well-Known Base URI Discovery Failed\"\n            self.logger.warning(\n                    \"%s - %s returned error code: %d\",\n                    msg, verify_url, status_code,\n            )\n            raise MatrixDiscoveryException(msg, error_code=status_code)\n\n        if not response:\n            # This is an acceptable response; we simply do nothing\n            self.logger.debug(\n                \"Matrix Well-Known Base URI not defined %s\", verify_url\n            )\n\n            # Set our keys out for fast recall later on\n            self.store.set(\n                self.discovery_base_key,\n                \"\",\n                expires=self.discovery_cache_length_sec,\n            )\n            self.store.set(\n                self.discovery_identity_key,\n                \"\",\n                expires=self.discovery_cache_length_sec,\n            )\n            return \"\"\n\n        #\n        # Parse our m.homeserver information\n        #\n        try:\n            base_url = response[\"m.homeserver\"][\"base_url\"].rstrip(\"/\")\n            results = NotifyBase.parse_url(base_url, verify_host=True)\n\n        except (AttributeError, TypeError, KeyError):\n            # AttributeError: result wasn't a string (rstrip failed)\n            # TypeError     : response wasn't a dictionary\n            # KeyError      : response not to standards\n            results = None\n\n        if not results:\n            msg = \"Matrix Well-Known Base URI Discovery Failed\"\n            self.logger.warning(\n                \"%s - m.homeserver payload is missing or invalid: %s\",\n                msg,\n                response,\n            )\n            raise MatrixDiscoveryException(msg)\n\n        #\n        # Our .well-known extraction was successful; now we need to verify\n        # that the version information resolves.\n        #\n        verify_url = f\"{base_url}/_matrix/client/versions\"\n        # Post our content\n        _, _, status_code = self._fetch(\n            None, method=\"GET\", url_override=verify_url\n        )\n        if status_code != requests.codes.ok:\n            # We're done early as we couldn't load the results\n            msg = \"Matrix Well-Known Base URI Discovery Verification Failed\"\n            self.logger.warning(\n                    \"%s - %s returned error code: %d\",\n                    msg, verify_url, status_code,\n            )\n            raise MatrixDiscoveryException(msg, error_code=status_code)\n\n        #\n        # Phase 2: Handle m.identity_server IF defined\n        #\n        if \"m.identity_server\" in response:\n            try:\n                identity_url = response[\"m.identity_server\"][\n                    \"base_url\"\n                ].rstrip(\"/\")\n                results = NotifyBase.parse_url(identity_url, verify_host=True)\n\n            except (AttributeError, TypeError, KeyError):\n                # AttributeError: result wasn't a string (rstrip failed)\n                # TypeError     : response wasn't a dictionary\n                # KeyError      : response not to standards\n                results = None\n\n            if not results:\n                msg = \"Matrix Well-Known Identity URI Discovery Failed\"\n                self.logger.warning(\n                    \"%s - m.identity_server payload is missing or invalid: %s\",\n                    msg,\n                    response,\n                )\n                raise MatrixDiscoveryException(msg)\n\n            #\n            #  Verify identity server found\n            #\n            verify_url = f\"{identity_url}/_matrix/identity/v2\"\n\n            # Post our content\n            _postokay, _, status_code = self._fetch(\n                None, method=\"GET\", url_override=verify_url\n            )\n            if status_code != requests.codes.ok:\n                # We're done early as we couldn't load the results\n                msg = \"Matrix Well-Known Identity URI Discovery Failed\"\n                self.logger.warning(\n                    \"%s - %s returned error code: %d\",\n                    msg, verify_url, status_code,\n                )\n                raise MatrixDiscoveryException(msg, error_code=status_code)\n\n            # Update our cache\n            self.store.set(\n                self.discovery_identity_key,\n                identity_url,\n                # Add 2 seconds to prevent this key from expiring before base\n                expires=self.discovery_cache_length_sec + 2,\n            )\n        else:\n            # No identity server\n            self.store.set(\n                self.discovery_identity_key,\n                \"\",\n                # Add 2 seconds to prevent this key from expiring before base\n                expires=self.discovery_cache_length_sec + 2,\n            )\n\n        # Update our cache\n        self.store.set(\n            self.discovery_base_key,\n            base_url,\n            expires=self.discovery_cache_length_sec,\n        )\n\n        return base_url\n\n    @property\n    def base_url(self):\n        \"\"\"Returns the base_url if known.\"\"\"\n        try:\n            base_url = self.server_discovery()\n            if base_url:\n                # We can use our cached value and return early\n                return base_url\n\n        except MatrixDiscoveryException:\n            self.store.clear(\n                self.discovery_base_key, self.discovery_identity_key\n            )\n            raise\n\n        # If we get hear, we need to build our URL dynamically based on what\n        # was provided to us during the plugins initialization\n        return \"{schema}://{hostname}{port}\".format(\n            schema=\"https\" if self.secure else \"http\",\n            hostname=self.host,\n            port=(\"\" if not self.port else f\":{self.port}\"),\n        )\n\n    @property\n    def identity_url(self):\n        \"\"\"Returns the identity_url if known.\"\"\"\n        base_url = self.base_url\n        identity_url = self.store.get(self.discovery_identity_key)\n        return identity_url if identity_url else base_url\n"
  },
  {
    "path": "apprise/plugins/mattermost.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\"\"\"\nMattermost Notifications.\n\nThis plugin supports 2 modes of operation:\n\n1. Webhook mode (default):\n   - Uses Mattermost Incoming Webhooks: /hooks/<webhook_token>\n   - Targets are channel names (for example: '#support' or 'support')\n   - If no targets are specified, Mattermost uses the webhook default\n\n2. Bot mode (mode=bot):\n   - Uses Mattermost REST API: /api/v4/posts\n   - Requires a Bot (or User) Access Token (Bearer token)\n   - Targets are channel_id values by default\n   - Channel name resolution is supported when a team is known\n\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom itertools import chain\n\n# Create an incoming webhook; the website will provide you with something like:\n#  http://localhost:8065/hooks/yobjmukpaw3r3urc5h6i369yima\n#                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n#                              |-- this is the webhook --|\n#\n# You can effectively turn the url above to read this:\n# mmost://localhost:8065/yobjmukpaw3r3urc5h6i369yima\n#  - swap http with mmost\n#  - drop /hooks/ reference\nfrom json import dumps, loads\nimport re\nfrom typing import Any\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType, PersistentStoreMode\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Some Reference Locations:\n# - https://docs.mattermost.com/developer/webhooks-incoming.html\n# - https://docs.mattermost.com/administration/config-settings.html\n\nIS_CHANNEL = re.compile(r\"^(#|%23)(?P<name>[A-Za-z0-9_-]+)$\")\nIS_CHANNEL_ID = re.compile(r\"^(\\+|%2B)?(?P<name>[A-Za-z0-9_-]+)$\")\n\n\nclass MattermostMode:\n    \"\"\"Supported Mattermost integration modes.\"\"\"\n\n    # Incoming webhook mode\n    WEBHOOK = \"webhook\"\n\n    # Bot API mode\n    BOT = \"bot\"\n\n\n# Define our Mattermost Modes\nMATTERMOST_MODES = (\n    MattermostMode.WEBHOOK,\n    MattermostMode.BOT,\n)\n\n\nclass NotifyMattermost(NotifyBase):\n    \"\"\"A wrapper for Mattermost Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Mattermost\"\n\n    # The services URL\n    service_url = \"https://mattermost.com/\"\n\n    # The default protocol\n    protocol = \"mmost\"\n\n    # The default secure protocol\n    secure_protocol = \"mmosts\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/mattermost/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 4000\n\n    # Mattermost does not have a title\n    title_maxlen = 0\n\n    # Allow persistent caching of bot channel lookups\n    storage_mode = PersistentStoreMode.AUTO\n\n    # Keep our cache for 20 days\n    default_cache_expiry_sec = 60 * 60 * 24 * 20\n\n    # Lower rate req since service is self hosted in most\n    # circumstances\n    request_rate_per_sec = 0.02\n\n    templates = (\n        \"{schema}://{host}/{token}\",\n        \"{schema}://{host}:{port}/{token}\",\n        \"{schema}://{host}/{fullpath}/{token}\",\n        \"{schema}://{host}:{port}/{fullpath}/{token}\",\n        \"{schema}://{user}@{host}/{token}\",\n        \"{schema}://{user}@{host}:{port}/{token}\",\n        \"{schema}://{user}@{host}/{fullpath}/{token}\",\n        \"{schema}://{user}@{host}:{port}/{fullpath}/{token}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User\"),\n                \"type\": \"string\",\n            },\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"token\": {\n                # Webhook Token (webhook mode) OR Access Token (bot mode)\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"fullpath\": {\n                \"name\": _(\"Path\"),\n                \"type\": \"string\",\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"target_channel_id\": {\n                \"name\": _(\"Target Channel ID\"),\n                \"type\": \"string\",\n                \"prefix\": \"\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"channel\": {\n                # Backwards compatible\n                \"alias_of\": \"targets\",\n            },\n            \"channels\": {\n                # Backwards compatible\n                \"alias_of\": \"targets\",\n            },\n            \"icon_url\": {\n                \"name\": _(\"Icon URL\"),\n                \"type\": \"string\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"team\": {\n                \"alias_of\": \"user\",\n            },\n            \"botname\": {\n                \"alias_of\": \"user\",\n            },\n            \"mode\": {\n                \"name\": _(\"Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": MATTERMOST_MODES,\n                \"default\": MATTERMOST_MODES[0],\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        token: str,\n        fullpath: str | None = None,\n        targets: list[str] | str | None = None,\n        include_image: bool = False,\n        icon_url: str | None = None,\n        mode: str | None = None,\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"Initialize Mattermost object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.schema = \"https\" if self.secure else \"http\"\n        self.fullpath = (\n            \"\" if not isinstance(fullpath, str) else fullpath.strip()\n        )\n\n        # Mode\n        if isinstance(mode, str) and mode.strip():\n            mode_ = mode.strip().lower()\n            self.mode = next(\n                (m for m in MATTERMOST_MODES if m.startswith(mode_)), None\n            )\n            if self.mode not in MATTERMOST_MODES:\n                msg = f\"The Mattermost mode specified ({mode}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.mode = self.template_args[\"mode\"][\"default\"]\n\n        # Token (webhook token in webhook mode, bearer token in bot mode)\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = f\"An invalid Mattermost Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Used for URL generation afterwards only\n        self._invalid_targets = []\n\n        # Channels:\n        self.targets = []\n        for target in parse_list(targets):\n            result = IS_CHANNEL.match(target)\n            if result:\n                if self.mode == MattermostMode.BOT and not self.user:\n                    # No team was defined and we're in BOT mode\n                    self.logger.warning(\n                        \"Mattermost bot mode requires a team to resolve \"\n                        \"%s, dropping it.\",\n                        target,\n                    )\n                    self._invalid_targets.append(target)\n                    continue\n\n                # store valid channel\n                self.targets.append((\"#\", result.group(\"name\")))\n                continue\n\n            result = IS_CHANNEL_ID.match(target)\n            if result:\n                if self.mode == MattermostMode.WEBHOOK:\n                    # store valid channel\n                    self.targets.append((\"#\", result.group(\"name\")))\n\n                else:  # MattermostMode.BOT\n                    # store valid channel_id\n                    self.targets.append((\"+\", result.group(\"name\")))\n                continue\n\n            self.logger.warning(\n                \"Dropping invalid Mattermost target %s\",\n                target,\n            )\n            self._invalid_targets.append(target)\n\n        # Webhook mode features (ignored in bot mode)\n        self.include_image = include_image\n\n        # Support a user-provided icon URL\n        self.icon_url = icon_url\n\n    def __len__(self) -> int:\n        \"\"\"Returns the number of outbound HTTP requests expected.\"\"\"\n        return max(1, len(self.targets))\n\n    def _channel_lookup(self, channel: str) -> str | None:\n        \"\"\"\n        Resolve a channel name to a channel_id.\n\n        Resolution occurs only during send(); results are persistently cached.\n        \"\"\"\n        # Attempt to pull from Persistent Storage if available\n        key = f\"c:{channel}\"\n        cached = self.store.get(key)\n        if cached:\n            return cached\n\n        port = \"\" if self.port is None else f\":{self.port}\"\n        team = NotifyMattermost.quote(self.user, safe=\"\")\n        name = NotifyMattermost.quote(channel, safe=\"\")\n\n        headers: dict[str, str] = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.token}\",\n        }\n\n        url = \"{}://{}{}{}/api/v4/teams/name/{}/channels/name/{}\".format(\n            self.schema,\n            self.host,\n            port,\n            self.fullpath.rstrip(\"/\"),\n            team,\n            name,\n        )\n\n        self.logger.debug(\n            \"Mattermost channel lookup URL: %s (cert_verify=%r)\",\n            url,\n            self.verify_certificate,\n        )\n\n        self.throttle()\n\n        try:\n            r = requests.get(\n                url,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                status = self.http_response_code_lookup(r.status_code)\n                self.logger.warning(\n                    \"Mattermost channel lookup failed for %s: %s, error=%d.\",\n                    channel,\n                    status,\n                    r.status_code,\n                )\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000]\n                )\n                return None\n\n            try:\n                data = loads(r.content.decode(\"utf-8\"))\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                self.logger.debug(\n                    \"Mattermost channel lookup response was not JSON:\\r\\n%r\",\n                    (r.content or b\"\")[:2000],\n                )\n                return None\n\n            channel_id = data.get(\"id\")\n            if not isinstance(channel_id, str) or not channel_id.strip():\n                return None\n\n            self.store.set(\n                key,\n                channel_id,\n                expires=self.default_cache_expiry_sec,\n            )\n            return channel_id\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred performing Mattermost channel \"\n                \"lookup for %s.\",\n                channel,\n            )\n            self.logger.debug(\"Socket Exception: %s\", e)\n            return None\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Perform Mattermost Notification.\"\"\"\n\n        if self.mode == MattermostMode.BOT and not self.targets:\n            self.logger.warning(\n                \"Mattermost BOT mode has no valid channels to notify, \"\n                \"aborting.\"\n            )\n            return False\n\n        # Initialize our error tracking\n        has_error = False\n\n        # Prepare our port reference in advance\n        port = \"\" if self.port is None else f\":{self.port}\"\n\n        headers: dict[str, str] = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        if self.mode == MattermostMode.BOT:\n            url = \"{}://{}{}{}/api/v4/posts\".format(\n                self.schema, self.host, port, self.fullpath.rstrip(\"/\")\n            )\n\n            # Append headers\n            headers[\"Authorization\"] = f\"Bearer {self.token}\"\n            expected = (requests.codes.created, requests.codes.ok)\n\n        else:  # self.mode == MattermostMode.WEBHOOK\n            url = \"{}://{}{}{}/hooks/{}\".format(\n                self.schema,\n                self.host,\n                port,\n                self.fullpath.rstrip(\"/\"),\n                self.token,\n            )\n            expected = (requests.codes.ok,)\n\n        # Iterate over our targets\n        targets = self.targets.copy()\n        if self.mode == MattermostMode.WEBHOOK and not targets:\n            targets = [(None, None)]\n\n        for kind, value in targets:\n            target = value\n            if kind == \"#\" and self.mode == MattermostMode.BOT:\n                target = self._channel_lookup(value)\n                if not target:\n                    has_error = True\n                    continue\n\n            if self.mode == MattermostMode.BOT:\n                payload: dict[str, Any] = {\n                    \"channel_id\": target,\n                    \"message\": body,\n                }\n\n            else:\n                payload: dict[str, Any] = {\n                    \"text\": body,\n                }\n\n                image_url = self.icon_url\n                if not image_url and self.include_image:\n                    image_url = self.image_url(notify_type)\n\n                if image_url:\n                    payload[\"icon_url\"] = image_url\n\n                payload[\"username\"] = (\n                    self.user if self.user else self.app_id\n                )\n\n                if target:\n                    payload[\"channel\"] = target\n\n            self.logger.debug(\n                \"Mattermost %s POST URL: %s (cert_verify=%r)\",\n                self.mode,\n                url,\n                self.verify_certificate,\n            )\n            self.logger.debug(\n                \"Mattermost %s Payload: %s\", self.mode, payload\n            )\n\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code not in expected:\n                    status = self.http_response_code_lookup(r.status_code)\n                    self.logger.warning(\n                        \"Failed to send Mattermost notification to \"\n                        \"%s: %s, error=%d.\",\n                        f\"channel_id {target}\"\n                        if self.mode == MattermostMode.BOT\n                        else f\"channel {target}\",\n                        status,\n                        r.status_code,\n                    )\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\",\n                        (r.content or b\"\")[:2000],\n                    )\n                    has_error = True\n                    continue\n\n                self.logger.info(\n                    \"Sent Mattermost %s notification to %s.\",\n                    self.mode,\n                    target,\n                )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Mattermost \"\n                    \"%s notification to %s.\",\n                    self.mode, target,\n                )\n                self.logger.debug(\"Socket Exception: %s\", e)\n                has_error = True\n                continue\n\n        # Return our overall status\n        return not has_error\n\n    @property\n    def url_identifier(self) -> tuple[Any, ...]:\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another similar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.mode,\n            self.token,\n            self.host,\n            self.port,\n            self.fullpath,\n            self.user if self.mode == MattermostMode.BOT else None,\n        )\n\n    def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        params: dict[str, Any] = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        if self.mode != self.template_args[\"mode\"][\"default\"]:\n            params[\"mode\"] = self.mode\n\n        if self.mode == MattermostMode.WEBHOOK and self.icon_url:\n            params[\"icon_url\"] = self.icon_url\n\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.targets:\n            # historically the value only accepted one channel and is\n            # therefore identified as 'channel'. Channels have always been\n            # optional, so that is why this setting is nested in an if block\n            entries = []\n            for kind, value in self.targets:\n                if kind == \"#\":\n                    entries.append(f\"#{value}\")\n                else:\n                    entries.append(f\"+{value}\")\n\n            params[\"to\"] = \",\".join(chain(\n                [NotifyMattermost.quote(v, safe=\"#+\") for v in entries],\n                [NotifyMattermost.quote(x, safe=\"\")\n                 for x in self._invalid_targets],\n            ))\n\n        default_port = 443 if self.secure else 80\n        default_schema = self.secure_protocol if self.secure else self.protocol\n\n        # Determine if there is a source present\n        source = \"\"\n        if self.user:\n            source = \"{source}@\".format(\n                source=NotifyMattermost.quote(self.user, safe=\"\"),\n            )\n\n        return (\n            \"{schema}://{source}{hostname}{port}{fullpath}{token}\"\n            \"/?{params}\".format(\n                schema=default_schema,\n                source=source,\n                # never encode hostname since we're expecting it to be a valid\n                # one\n                hostname=self.host,\n                port=(\n                    \"\"\n                    if self.port is None or self.port == default_port\n                    else f\":{self.port}\"\n                ),\n                fullpath=(\n                    \"/\"\n                    if not self.fullpath\n                    else \"{}/\".format(\n                        NotifyMattermost.quote(self.fullpath, safe=\"/\")\n                    )\n                ),\n                token=self.pprint(self.token, privacy, safe=\"\"),\n                params=NotifyMattermost.urlencode(params),\n            )\n        )\n\n    @staticmethod\n    def parse_url(url: str):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to\n        re-instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Acquire our tokens; the last one will always be our token\n        # all entries before it will be our path\n        tokens = NotifyMattermost.split_path(results[\"fullpath\"])\n\n        results[\"token\"] = None if not tokens else tokens.pop()\n        results[\"fullpath\"] = (\n            \"\" if not tokens else \"/{}\".format(\"/\".join(tokens))\n        )\n\n        # Define our optional list of channels to notify\n        results[\"targets\"] = []\n\n        # Support both 'to' (for yaml configuration) and channel(s)=\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            # Allow the user to specify the channel to post to\n            results[\"targets\"].extend(\n                NotifyMattermost.parse_list(results[\"qsd\"][\"to\"])\n            )\n\n        if \"channel\" in results[\"qsd\"] and len(results[\"qsd\"][\"channel\"]):\n            results[\"targets\"].extend(\n                NotifyMattermost.parse_list(results[\"qsd\"][\"channel\"])\n            )\n\n        if \"channels\" in results[\"qsd\"] and len(results[\"qsd\"][\"channels\"]):\n            # Allow the user to specify the channel to post to\n            results[\"targets\"].extend(\n                NotifyMattermost.parse_list(results[\"qsd\"][\"channels\"])\n            )\n\n        # Image manipulation\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyMattermost.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # Our Mode\n        if \"mode\" in results[\"qsd\"] and results[\"qsd\"][\"mode\"]:\n            results[\"mode\"] = NotifyMattermost.unquote(\n                results[\"qsd\"][\"mode\"]\n            )\n\n        # Team support (bot mode lookup). This maps to `user`.\n        if \"team\" in results[\"qsd\"] and results[\"qsd\"][\"team\"]:\n            results[\"user\"] = NotifyMattermost.unquote(\n                results[\"qsd\"][\"team\"]\n            )\n            if \"mode\" not in results:\n                results[\"mode\"] = MattermostMode.BOT\n\n        elif \"botname\" in results[\"qsd\"] and results[\"qsd\"][\"botname\"]:\n            results[\"user\"] = NotifyMattermost.unquote(\n                results[\"qsd\"][\"botname\"]\n            )\n\n        if \"icon_url\" in results[\"qsd\"]:\n            results[\"icon_url\"] = NotifyMattermost.unquote(\n                results[\"qsd\"][\"icon_url\"]\n            )\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url: str) -> dict[str, Any] | None:\n        \"\"\"\n        Support parsing the webhook straight from URL\n            https://HOST:443/workflows/WORKFLOWID/triggers/manual/paths/invoke\n            https://mattermost.HOST/hooks/TOKEN\n        \"\"\"\n\n        # Match our workflows webhook URL and re-assemble\n        result = re.match(\n            r\"^http(?P<secure>s?)://(?P<host>mattermost\\.[A-Z0-9_.-]+)\"\n            r\"(:(?P<port>[1-9][0-9]{0,5}))?\"\n            r\"/hooks/\"\n            r\"(?P<token>[A-Z0-9_-]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            default_port = (\n                int(result.group(\"port\"))\n                if result.group(\"port\")\n                else (443 if result.group(\"secure\") else 80)\n            )\n\n            default_schema = (\n                NotifyMattermost.secure_protocol\n                if result.group(\"secure\")\n                else NotifyMattermost.protocol\n            )\n\n            # Construct our URL\n            return NotifyMattermost.parse_url(\n                \"{schema}://{host}{port}/{token}/{params}\".format(\n                    schema=default_schema,\n                    host=result.group(\"host\"),\n                    port=(\n                        \"\"\n                        if not result.group(\"port\")\n                        or int(result.group(\"port\")) == default_port\n                        else f\":{default_port}\"\n                    ),\n                    token=result.group(\"token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n        return None\n"
  },
  {
    "path": "apprise/plugins/messagebird.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Create an account https://messagebird.com if you don't already have one\n#\n# Get your (apikey) and api example from the dashboard here:\n#   - https://dashboard.messagebird.com/en/user/index\n#\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyMessageBird(NotifyBase):\n    \"\"\"A wrapper for MessageBird Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"MessageBird\"\n\n    # The services URL\n    service_url = \"https://messagebird.com\"\n\n    # The default protocol\n    secure_protocol = \"msgbird\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/messagebird/\"\n\n    # MessageBird uses the http protocol with JSON requests\n    notify_url = \"https://rest.messagebird.com/messages\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}/{source}\",\n        \"{schema}://{apikey}/{source}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9]{25}$\", \"i\"),\n            },\n            \"source\": {\n                \"name\": _(\"Source Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"required\": True,\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"from\": {\n                \"alias_of\": \"source\",\n            },\n        },\n    )\n\n    def __init__(self, apikey, source, targets=None, **kwargs):\n        \"\"\"Initialize MessageBird Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid MessageBird API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_phone_no(source)\n        if not result:\n            msg = f\"The MessageBird source specified ({source}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our source\n        self.source = result[\"full\"]\n\n        # Parse our targets\n        self.targets = []\n\n        targets = parse_phone_no(targets)\n        if not targets:\n            # No sources specified, use our own phone no\n            self.targets.append(self.source)\n            return\n\n        # otherwise, store all of our target numbers\n        for target in targets:\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform MessageBird Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no MessageBird targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n            \"Authorization\": f\"AccessKey {self.apikey}\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"originator\": f\"+{self.source}\",\n            \"recipients\": None,\n            \"body\": body,\n        }\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our user\n            payload[\"recipients\"] = f\"+{target}\"\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"MessageBird POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"MessageBird Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # Sample output of a successful transmission\n                # {\n                #   \"originator\": \"+15553338888\",\n                #   \"body\": \"test\",\n                #   \"direction\": \"mt\",\n                #   \"mclass\": 1,\n                #   \"reference\": null,\n                #   \"createdDatetime\": \"2019-08-22T01:32:18+00:00\",\n                #   \"recipients\": {\n                #     \"totalCount\": 1,\n                #     \"totalSentCount\": 1,\n                #     \"totalDeliveredCount\": 0,\n                #     \"totalDeliveryFailedCount\": 0,\n                #     \"items\": [\n                #       {\n                #         \"status\": \"sent\",\n                #         \"statusDatetime\": \"2019-08-22T01:32:18+00:00\",\n                #         \"recipient\": 15553338888,\n                #         \"messagePartCount\": 1\n                #       }\n                #     ]\n                #   },\n                #   \"validity\": null,\n                #   \"gateway\": 10,\n                #   \"typeDetails\": {},\n                #   \"href\": \"https://rest.messagebird.com/messages/\\\n                #       b5d424244a5b4fd0b5b5728bccaafc23\",\n                #   \"datacoding\": \"plain\",\n                #   \"scheduledDatetime\": null,\n                #   \"type\": \"sms\",\n                #   \"id\": \"b5d424244a5b4fd0b5b5728bccaafc23\"\n                # }\n\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.created,\n                ):\n                    # We had a problem\n                    status_str = NotifyMessageBird.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send MessageBird notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            \",\".join(target),\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent MessageBird notification to {target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending\"\n                    f\" MessageBird:{target} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey, self.source)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{apikey}/{source}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            source=self.source,\n            targets=\"/\".join(\n                [NotifyMessageBird.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyMessageBird.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyMessageBird.split_path(results[\"fullpath\"])\n\n        try:\n            # The first path entry is the source/originator\n            results[\"source\"] = results[\"targets\"].pop(0)\n\n        except IndexError:\n            # No path specified... this URL is potentially un-parseable; we can\n            # hope for a from= entry\n            results[\"source\"] = None\n\n        # The hostname is our authentication key\n        results[\"apikey\"] = NotifyMessageBird.unquote(results[\"host\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyMessageBird.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyMessageBird.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/misskey.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# 1. visit https://misskey-hub.net/ and see what it's all about if you want.\n#    Choose a service you want to create an account on from here:\n#    https://misskey-hub.net/en/instances.html\n#\n#    - For this plugin, I tested using https://misskey.sda1.net and created an\n#      account.\n#\n# 2. Generate an API Key:\n#    - Settings > API > Generate Key\n#      - Name it whatever you want\n#      - Assign it 'AT LEAST':\n#          a. Compose or delete chat messages\n#          b. Compose or delete notes\n#\n#\n# This plugin also supports taking the URL (as identified above) directly\n# as well.\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass MisskeyVisibility:\n    \"\"\"The visibility of any note created.\"\"\"\n\n    # post will be public\n    PUBLIC = \"public\"\n\n    HOME = \"home\"\n\n    FOLLOWERS = \"followers\"\n\n    SPECIFIED = \"specified\"\n\n\n# Define the types in a list for validation purposes\nMISSKEY_VISIBILITIES = (\n    MisskeyVisibility.PUBLIC,\n    MisskeyVisibility.HOME,\n    MisskeyVisibility.FOLLOWERS,\n    MisskeyVisibility.SPECIFIED,\n)\n\n\nclass NotifyMisskey(NotifyBase):\n    \"\"\"A wrapper for Misskey Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Misskey\"\n\n    # The services URL\n    service_url = \"https://misskey-hub.net/\"\n\n    # The default protocol\n    protocol = \"misskey\"\n\n    # The default secure protocol\n    secure_protocol = \"misskeys\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/misskey/\"\n\n    # The title is not used\n    title_maxlen = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 512\n\n    # Define object templates\n    templates = (\"{schema}://{project_id}/{msghook}\",)\n\n    # Define object templates\n    templates = (\n        \"{schema}://{token}@{host}\",\n        \"{schema}://{token}@{host}:{port}\",\n    )\n\n    # Define our template arguments\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"visibility\": {\n                \"name\": _(\"Visibility\"),\n                \"type\": \"choice:string\",\n                \"values\": MISSKEY_VISIBILITIES,\n                \"default\": MisskeyVisibility.PUBLIC,\n            },\n        },\n    )\n\n    def __init__(self, token=None, visibility=None, **kwargs):\n        \"\"\"Initialize Misskey Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = \"An invalid Misskey Access Token was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if visibility:\n            # Input is a string; attempt to get the lookup from our\n            # sound mapping\n            vis = (\n                \"invalid\"\n                if not isinstance(visibility, str)\n                else visibility.lower().strip()\n            )\n\n            # This little bit of black magic allows us to match against\n            # against multiple versions of the same string ... etc\n            self.visibility = next(\n                (v for v in MISSKEY_VISIBILITIES if v.startswith(vis)), None\n            )\n\n            if self.visibility not in MISSKEY_VISIBILITIES:\n                msg = (\n                    f\"The Misskey visibility specified ({visibility}) is\"\n                    \" invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.visibility = self.template_args[\"visibility\"][\"default\"]\n\n        # Prepare our URL\n        self.schema = \"https\" if self.secure else \"http\"\n        self.api_url = f\"{self.schema}://{self.host}\"\n\n        if isinstance(self.port, int):\n            self.api_url += f\":{self.port}\"\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.token,\n            self.host,\n            self.port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        params = {\n            \"visibility\": self.visibility,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        host = self.host\n        if isinstance(self.port, int):\n            host += f\":{self.port}\"\n\n        return \"{schema}://{token}@{host}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            host=host,\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            params=NotifyMisskey.urlencode(params),\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Wrapper to _send since we can alert more then one channel.\"\"\"\n\n        # prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"i\": self.token,\n            \"text\": body,\n            \"visibility\": self.visibility,\n        }\n\n        api_url = f\"{self.api_url}/api/notes/create\"\n        self.logger.debug(\n            \"Misskey GET URL:\"\n            f\" {api_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Misskey Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                api_url,\n                headers=headers,\n                data=dumps(payload),\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyMisskey.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Misskey notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Misskey notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Misskey notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyMisskey.unquote(results[\"qsd\"][\"token\"])\n\n        elif not results[\"password\"] and results[\"user\"]:\n            results[\"token\"] = NotifyMisskey.unquote(results[\"user\"])\n\n        # Capture visibility if specified\n        if \"visibility\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"visibility\"]\n        ):\n            results[\"visibility\"] = NotifyMisskey.unquote(\n                results[\"qsd\"][\"visibility\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/mqtt.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# PAHO MQTT Documentation:\n#  https://www.eclipse.org/paho/index.php?page=clients/python/docs/index.php\n#\n# Looking at the PAHO MQTT Source can help shed light on what's going on too\n# as their inline documentation is pretty good!\n#   https://github.com/eclipse/paho.mqtt.python\\\n#           /blob/master/src/paho/mqtt/client.py\nfrom datetime import datetime\nfrom os.path import isfile\nimport re\nimport ssl\nfrom time import sleep\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, parse_list\nfrom .base import NotifyBase\n\n# Default our global support flag\nNOTIFY_MQTT_SUPPORT_ENABLED = False\n\ntry:\n    # 3rd party modules\n    import paho.mqtt.client as mqtt\n\n    # We're good to go!\n    NOTIFY_MQTT_SUPPORT_ENABLED = True\n\n    MQTT_PROTOCOL_MAP = {\n        # v3.1.1\n        \"311\": mqtt.MQTTv311,\n        # v3.1\n        \"31\": mqtt.MQTTv31,\n        # v5.0\n        \"5\": mqtt.MQTTv5,\n        # v5.0 (alias)\n        \"50\": mqtt.MQTTv5,\n    }\n\nexcept ImportError:\n    # No problem; we just simply can't support this plugin because we're\n    # either using Linux, or simply do not have pywin32 installed.\n    MQTT_PROTOCOL_MAP = {}\n\n# A lookup map for relaying version to user\nHUMAN_MQTT_PROTOCOL_MAP = {\n    \"v3.1.1\": \"311\",\n    \"v3.1\": \"31\",\n    \"v5.0\": \"5\",\n}\n\n\nclass NotifyMQTT(NotifyBase):\n    \"\"\"A wrapper for MQTT Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_MQTT_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"packages_required\": \"paho-mqtt != 2.0.*\"\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = \"MQTT Notification\"\n\n    # The default protocol\n    protocol = \"mqtt\"\n\n    # Secure protocol\n    secure_protocol = \"mqtts\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/mqtt/\"\n\n    # MQTT does not have a title\n    title_maxlen = 0\n\n    # The maximum length a body can be set to\n    body_maxlen = 268435455\n\n    # Use a throttle; but it doesn't need to be so strict since most\n    # MQTT server hostings can handle the small bursts of packets and are\n    # locally hosted anyway\n    request_rate_per_sec = 0.5\n\n    # Port Defaults (unless otherwise specified)\n    mqtt_insecure_port = 1883\n\n    # The default secure port to use (if mqtts://)\n    mqtt_secure_port = 8883\n\n    # The default mqtt keepalive value\n    mqtt_keepalive = 30\n\n    # The default mqtt transport\n    mqtt_transport = \"tcp\"\n\n    # The number of seconds to wait for a publish to occur at before\n    # checking to see if it's been sent yet.\n    mqtt_block_time_sec = 0.2\n\n    # Set the maximum number of messages with QoS>0 that can be part way\n    # through their network flow at once.\n    mqtt_inflight_messages = 200\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}@{host}/{topic}\",\n        \"{schema}://{user}@{host}:{port}/{topic}\",\n        \"{schema}://{user}:{password}@{host}/{topic}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{topic}\",\n    )\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"topic\": {\n                \"name\": _(\"Target Queue\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"qos\": {\n                \"name\": _(\"QOS\"),\n                \"type\": \"int\",\n                \"default\": 0,\n                \"min\": 0,\n                \"max\": 2,\n            },\n            \"version\": {\n                \"name\": _(\"Version\"),\n                \"type\": \"choice:string\",\n                \"values\": HUMAN_MQTT_PROTOCOL_MAP,\n                \"default\": \"v3.1.1\",\n            },\n            \"client_id\": {\n                \"name\": _(\"Client ID\"),\n                \"type\": \"string\",\n            },\n            \"session\": {\n                \"name\": _(\"Use Session\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"retain\": {\n                \"name\": _(\"Retain Messages\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        targets=None,\n        version=None,\n        qos=None,\n        client_id=None,\n        session=None,\n        retain=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize MQTT Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        # Initialize topics\n        self.topics = parse_list(targets)\n\n        if version is None:\n            self.version = self.template_args[\"version\"][\"default\"]\n        else:\n            self.version = version\n\n        # Save our client id if specified\n        self.client_id = client_id\n\n        # Maintain our session (associated with our user id if set)\n        self.session = (\n            self.template_args[\"session\"][\"default\"]\n            if session is None or not self.client_id\n            else parse_bool(session)\n        )\n\n        # Our Retain Message Flag\n        self.retain = (\n            self.template_args[\"retain\"][\"default\"]\n            if retain is None\n            else parse_bool(retain)\n        )\n\n        # Set up our Quality of Service (QoS)\n        try:\n            self.qos = (\n                self.template_args[\"qos\"][\"default\"]\n                if qos is None\n                else int(qos)\n            )\n\n            if (\n                self.qos < self.template_args[\"qos\"][\"min\"]\n                or self.qos > self.template_args[\"qos\"][\"max\"]\n            ):\n                # Let error get handle on exceptio higher up\n                raise ValueError(\"\")\n\n        except (ValueError, TypeError):\n            msg = f\"An invalid MQTT QOS ({qos}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        if not self.port:\n            # Assign port (if not otherwise set)\n            self.port = (\n                self.mqtt_secure_port\n                if self.secure\n                else self.mqtt_insecure_port\n            )\n\n        self.ca_certs = None\n        if self.secure:\n            # verify SSL key or abort\n            # TODO: There is no error reporting or aborting here?\n            #       It could be useful to inform the user _where_ Apprise\n            #       tried to find the root CA certificates file.\n            self.ca_certs = next(\n                (\n                    cert\n                    for cert in self.CA_CERTIFICATE_FILE_LOCATIONS\n                    if isfile(cert)\n                ),\n                None,\n            )\n\n        # Set up our MQTT Publisher\n        try:\n            # Get our protocol\n            self.mqtt_protocol = MQTT_PROTOCOL_MAP[\n                re.sub(r\"[^0-9]+\", \"\", self.version)\n            ]\n\n        except KeyError:\n            msg = (\n                f\"An invalid MQTT Protocol version ({version}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        # Our MQTT Client Object\n        self.client = mqtt.Client(\n            client_id=self.client_id,\n            clean_session=not self.session,\n            userdata=None,\n            protocol=self.mqtt_protocol,\n            transport=self.mqtt_transport,\n        )\n\n        # Our maximum number of in-flight messages\n        self.client.max_inflight_messages_set(self.mqtt_inflight_messages)\n\n        # Toggled to False once our connection has been established at least\n        # once\n        self.__initial_connect = True\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform MQTT Notification.\"\"\"\n\n        if len(self.topics) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no MQTT topics to notify.\")\n            return False\n\n        # For logging:\n        url = f\"{self.host}:{self.port}\"\n\n        try:\n            if self.__initial_connect:\n                # Our initial connection\n                if self.user:\n                    self.client.username_pw_set(\n                        self.user, password=self.password\n                    )\n\n                if self.secure:\n                    if self.ca_certs is None:\n                        self.logger.error(\n                            \"MQTT secure communication can not be verified, \"\n                            \"CA certificates file missing\"\n                        )\n                        return False\n\n                    self.client.tls_set(\n                        ca_certs=self.ca_certs,\n                        certfile=None,\n                        keyfile=None,\n                        cert_reqs=ssl.CERT_REQUIRED,\n                        tls_version=ssl.PROTOCOL_TLS,\n                        ciphers=None,\n                    )\n\n                    # Set our TLS Verify Flag\n                    self.client.tls_insecure_set(not self.verify_certificate)\n\n                # Establish our connection\n                if (\n                    self.client.connect(\n                        self.host,\n                        port=self.port,\n                        keepalive=self.mqtt_keepalive,\n                    )\n                    != mqtt.MQTT_ERR_SUCCESS\n                ):\n                    self.logger.warning(\n                        \"An MQTT connection could not be established for\"\n                        f\" {url}\"\n                    )\n                    return False\n\n                # Start our client loop\n                self.client.loop_start()\n\n                # Throttle our start otherwise the starting handshaking doesnt\n                # work. I'm not sure if this is a bug or not, but with qos=0,\n                # and without this sleep(), the messages randomly fails to be\n                # delivered.\n                sleep(0.01)\n\n                # Toggle our flag since we never need to enter this area again\n                self.__initial_connect = False\n\n            # Create a copy of the subreddits list\n            topics = list(self.topics)\n\n            has_error = False\n            while len(topics) > 0 and not has_error:\n                # Retrieve our subreddit\n                topic = topics.pop()\n\n                # For logging:\n                url = f\"{self.host}:{self.port}/{topic}\"\n\n                # Always call throttle before any remote server i/o is made\n                self.throttle()\n\n                # handle a re-connection\n                if (\n                    not self.client.is_connected()\n                    and self.client.reconnect() != mqtt.MQTT_ERR_SUCCESS\n                ):\n                    self.logger.warning(\n                        f\"An MQTT connection could not be sustained for {url}\"\n                    )\n                    has_error = True\n                    break\n\n                # Some Debug Logging\n                self.logger.debug(\n                    \"MQTT POST URL:\"\n                    f\" {url} (cert_verify={self.verify_certificate})\"\n                )\n                self.logger.debug(f\"MQTT Payload: {body!s}\")\n\n                result = self.client.publish(\n                    topic, payload=body, qos=self.qos, retain=self.retain\n                )\n\n                if result.rc != mqtt.MQTT_ERR_SUCCESS:\n                    # Toggle our status\n                    self.logger.warning(\n                        f\"An error (rc={result.rc}) occured when sending MQTT\"\n                        f\" to {url}\"\n                    )\n                    has_error = True\n                    break\n\n                elif not result.is_published():\n                    self.logger.debug(\n                        \"Blocking until MQTT payload is published...\"\n                    )\n                    reference = datetime.now()\n                    while not has_error and not result.is_published():\n                        # Throttle\n                        sleep(self.mqtt_block_time_sec)\n\n                        # Our own throttle so we can abort eventually....\n                        elapsed = (datetime.now() - reference).total_seconds()\n                        if elapsed >= self.socket_read_timeout:\n                            self.logger.warning(\n                                \"The MQTT message could not be delivered\"\n                            )\n                            has_error = True\n\n                # if we reach here; we're at the bottom of our loop\n                # we loop around and do the next topic now\n\n        except ConnectionError as e:\n            self.logger.warning(f\"MQTT Connection Error received from {url}\")\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        except ssl.CertificateError as e:\n            self.logger.warning(\n                f\"MQTT SSL Certificate Error received from {url}\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        except ValueError as e:\n            # ValueError's are thrown from publish() call if there is a problem\n            self.logger.warning(f\"MQTT Publishing error received: from {url}\")\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        if not has_error:\n            # Verbal notice\n            self.logger.info(\"Sent MQTT notification\")\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            (\n                self.port\n                if self.port\n                else (\n                    self.mqtt_secure_port\n                    if self.secure\n                    else self.mqtt_insecure_port\n                )\n            ),\n            self.fullpath.rstrip(\"/\"),\n            self.client_id,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"version\": self.version,\n            \"qos\": str(self.qos),\n            \"session\": \"yes\" if self.session else \"no\",\n            \"retain\": \"yes\" if self.retain else \"no\",\n        }\n\n        if self.client_id:\n            # Our client id is set if specified\n            params[\"client_id\"] = self.client_id\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyMQTT.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyMQTT.quote(self.user, safe=\"\"),\n            )\n\n        default_port = (\n            self.mqtt_secure_port if self.secure else self.mqtt_insecure_port\n        )\n\n        return \"{schema}://{auth}{hostname}{port}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            targets=\",\".join(\n                [NotifyMQTT.quote(x, safe=\"/\") for x in self.topics]\n            ),\n            params=NotifyMQTT.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.topics)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"There are no parameters nessisary for this protocol; simply having\n        windows:// is all you need.\n\n        This function just makes sure that is in place.\n        \"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        try:\n            # Acquire topic(s)\n            results[\"targets\"] = parse_list(\n                NotifyMQTT.unquote(results[\"fullpath\"].lstrip(\"/\"))\n            )\n\n        except AttributeError:\n            # No 'fullpath' specified\n            results[\"targets\"] = []\n\n        # The MQTT protocol version to use\n        if \"version\" in results[\"qsd\"] and len(results[\"qsd\"][\"version\"]):\n            results[\"version\"] = NotifyMQTT.unquote(results[\"qsd\"][\"version\"])\n\n        # The MQTT Client ID\n        if \"client_id\" in results[\"qsd\"] and len(results[\"qsd\"][\"client_id\"]):\n            results[\"client_id\"] = NotifyMQTT.unquote(\n                results[\"qsd\"][\"client_id\"]\n            )\n\n        if \"session\" in results[\"qsd\"] and len(results[\"qsd\"][\"session\"]):\n            results[\"session\"] = parse_bool(results[\"qsd\"][\"session\"])\n\n        # Message Retain Flag\n        if \"retain\" in results[\"qsd\"] and len(results[\"qsd\"][\"retain\"]):\n            results[\"retain\"] = parse_bool(results[\"qsd\"][\"retain\"])\n\n        # The MQTT Quality of Service to use\n        if \"qos\" in results[\"qsd\"] and len(results[\"qsd\"][\"qos\"]):\n            results[\"qos\"] = NotifyMQTT.unquote(results[\"qsd\"][\"qos\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].extend(\n                NotifyMQTT.parse_list(results[\"qsd\"][\"to\"])\n            )\n\n        # return results\n        return results\n\n    @property\n    def CA_CERTIFICATE_FILE_LOCATIONS(self):\n        \"\"\"Return possible locations to root certificate authority (CA)\n        bundles.\n\n        Taken from https://golang.org/src/crypto/x509/root_linux.go\n        TODO: Maybe refactor to a general utility function?\n        \"\"\"\n        candidates = [\n            # Debian/Ubuntu/Gentoo etc.\n            \"/etc/ssl/certs/ca-certificates.crt\",\n            # Fedora/RHEL 6\n            \"/etc/pki/tls/certs/ca-bundle.crt\",\n            # OpenSUSE\n            \"/etc/ssl/ca-bundle.pem\",\n            # OpenELEC\n            \"/etc/pki/tls/cacert.pem\",\n            # CentOS/RHEL 7\n            \"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem\",\n            # macOS Homebrew; brew install ca-certificates\n            \"/usr/local/etc/ca-certificates/cert.pem\",\n        ]\n\n        # Certifi provides Mozilla's carefully curated collection of Root\n        # Certificates for validating the trustworthiness of SSL certificates\n        # while verifying the identity of TLS hosts. It has been extracted from\n        # the Requests project.\n        try:\n            import certifi\n\n            candidates.append(certifi.where())\n        except ImportError:  # pragma: no cover\n            pass\n\n        return candidates\n"
  },
  {
    "path": "apprise/plugins/msg91.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Create an account https://msg91.com/ if you don't already have one\n#\n# Get your (authkey) from the dashboard here:\n#   - https://world.msg91.com/user/index.php#api\n#\n# Note: You will need to define a template for this to work\n#\n# Get details on the API used in this plugin here:\n#   - https://docs.msg91.com/reference/send-sms\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_bool,\n    parse_phone_no,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n\nclass MSG91PayloadField:\n    \"\"\"Identifies the fields available in the JSON Payload.\"\"\"\n\n    BODY = \"body\"\n    MESSAGETYPE = \"type\"\n\n\n# Add entries here that are reserved\nRESERVED_KEYWORDS = (\"mobiles\",)\n\n\nclass NotifyMSG91(NotifyBase):\n    \"\"\"A wrapper for MSG91 Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"MSG91\"\n\n    # The services URL\n    service_url = \"https://msg91.com\"\n\n    # The default protocol\n    secure_protocol = \"msg91\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/msg91/\"\n\n    # MSG91 uses the http protocol with JSON requests\n    notify_url = \"https://control.msg91.com/api/v5/flow/\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Our supported mappings and component keys\n    component_key_re = re.compile(\n        r\"(?P<key>((?P<id>[a-z0-9_-])?|(?P<map>body|type)))\", re.IGNORECASE\n    )\n\n    # Define object templates\n    templates = (\"{schema}://{template}@{authkey}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"template\": {\n                \"name\": _(\"Template ID\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9 _-]+$\", \"i\"),\n            },\n            \"authkey\": {\n                \"name\": _(\"Authentication Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"short_url\": {\n                \"name\": _(\"Short URL\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"template_mapping\": {\n            \"name\": _(\"Template Mapping\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        template,\n        authkey,\n        targets=None,\n        short_url=None,\n        template_mapping=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize MSG91 Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Authentication Key (associated with project)\n        self.authkey = validate_regex(\n            authkey, *self.template_tokens[\"authkey\"][\"regex\"]\n        )\n        if not self.authkey:\n            msg = (\n                \"An invalid MSG91 Authentication Key \"\n                f\"({authkey}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Template ID\n        self.template = validate_regex(\n            template, *self.template_tokens[\"template\"][\"regex\"]\n        )\n        if not self.template:\n            msg = f\"An invalid MSG91 Template ID ({template}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if short_url is None:\n            self.short_url = self.template_args[\"short_url\"][\"default\"]\n\n        else:\n            self.short_url = parse_bool(short_url)\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n        self.template_mapping = {}\n        if template_mapping:\n            # Store our extra payload entries\n            self.template_mapping.update(template_mapping)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform MSG91 Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no MSG91 targets to notify.\")\n            return False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"authkey\": self.authkey,\n        }\n\n        # Base\n        recipient_payload = {\n            \"mobiles\": None,\n            # Keyword Tokens\n            MSG91PayloadField.BODY: body,\n            MSG91PayloadField.MESSAGETYPE: notify_type.value,\n        }\n\n        # Prepare Recipient Payload Object\n        for key, value in self.template_mapping.items():\n\n            if key in RESERVED_KEYWORDS:\n                self.logger.warning(\n                    \"Ignoring MSG91 custom payload entry %s\", key\n                )\n                continue\n\n            if key in recipient_payload:\n                if not value:\n                    # Do not store element in payload response\n                    del recipient_payload[key]\n\n                else:\n                    # Re-map\n                    recipient_payload[value] = recipient_payload[key]\n                    del recipient_payload[key]\n\n            else:\n                # Append entry\n                recipient_payload[key] = value\n\n        # Prepare our recipients\n        recipients = []\n        for target in self.targets:\n            recipient = recipient_payload.copy()\n            recipient[\"mobiles\"] = target\n            recipients.append(recipient)\n\n        # Prepare our payload\n        payload = {\n            \"template_id\": self.template,\n            \"short_url\": 1 if self.short_url else 0,\n            # target phone numbers are sent with a comma delimiter\n            \"recipients\": recipients,\n        }\n\n        # Some Debug Logging\n        self.logger.debug(\n            \"MSG91 POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"MSG91 Payload: {payload}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyMSG91.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send MSG91 notification to {}: \"\n                    \"{}{}error={}.\".format(\n                        \",\".join(self.targets),\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False\n\n            else:\n                self.logger.info(\n                    \"Sent MSG91 notification to {}.\".format(\n                        \",\".join(self.targets)\n                    )\n                )\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending MSG91:{} \"\n                \"notification.\".format(\",\".join(self.targets))\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.template, self.authkey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"short_url\": str(self.short_url),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Payload body extras prefixed with a ':' sign\n        # Append our payload extras into our parameters\n        params.update({f\":{k}\": v for k, v in self.template_mapping.items()})\n\n        return \"{schema}://{template}@{authkey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            template=self.pprint(self.template, privacy, safe=\"\"),\n            authkey=self.pprint(self.authkey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyMSG91.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyMSG91.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyMSG91.split_path(results[\"fullpath\"])\n\n        # The hostname is our authentication key\n        results[\"authkey\"] = NotifyMSG91.unquote(results[\"host\"])\n\n        # The template id is kept in the user field\n        results[\"template\"] = NotifyMSG91.unquote(results[\"user\"])\n\n        if \"short_url\" in results[\"qsd\"] and len(results[\"qsd\"][\"short_url\"]):\n            results[\"short_url\"] = parse_bool(results[\"qsd\"][\"short_url\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyMSG91.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # store any additional payload extra's defined\n        results[\"template_mapping\"] = {\n            NotifyMSG91.unquote(x): NotifyMSG91.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/msteams.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you need to create a webhook; you can read more about\n# this here:\n#    https://dev.outlook.com/Connectors/\\\n#       GetStarted#creating-messages-through-office-365-connectors-\\\n#           in-microsoft-teams\n#\n# More details are here on API Construction:\n#    https://docs.microsoft.com/en-ca/outlook/actionable-messages/\\\n#        message-card-reference\n#\n# I personally created a free account at teams.microsoft.com and then\n# went to the store (bottom left hand side of slack like interface).\n#\n# From here you can search for 'Incoming Webhook'. Once you click on it,\n# you can associate the webhook with your team. At this point, you can\n# optionally also assign it a name, an avatar.  Finally you'll have to\n# assign it a channel it will notify.\n#\n# When you've completed this, it will generate you a (webhook) URL that\n# looks like:\n#   https://team-name.webhook.office.com/webhookb2/ \\\n#       abcdefgf8-2f4b-4eca-8f61-225c83db1967@abcdefg2-5a99-4849-8efc-\\\n#        c9e78d28e57d/IncomingWebhook/291289f63a8abd3593e834af4d79f9fe/\\\n#          a2329f43-0ffb-46ab-948b-c9abdad9d643\n#\n# Yes... The URL is that big... But it looks like this (greatly simplified):\n# https://TEAM-NAME.webhook.office.com/webhookb2/ABCD/IncomingWebhook/DEFG/HIJK\n#             ^                                   ^                    ^    ^\n#             |                                   |                    |    |\n#  These are important <--------------------------^--------------------^----^\n#\n\n# The Legacy format didn't have the team name identified and reads 'outlook'\n# While this still works, consider that Microsoft will be dropping support\n# for this soon, so you may need to update your IncomingWebhook. Here is\n# what a legacy URL looked like:\n# https://outlook.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK\n#           ^                         ^                    ^    ^\n#           |                         |                    |    |\n#   legacy team reference: 'outlook'  |                    |    |\n#                                     |                    |    |\n#  These are important <--------------^--------------------^----^\n#\n\n# You'll notice that the first token is actually 2 separated by an @ symbol\n# But lets just ignore that and assume it's one great big token instead.\n#\n# These 3 tokens need to be placed in the URL after the Team\n#   msteams://TEAM/ABCD/DEFG/HIJK\n#\nimport json\nfrom json.decoder import JSONDecodeError\nimport re\n\nimport requests\n\nfrom ..apprise_attachment import AppriseAttachment\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, validate_regex\nfrom ..utils.templates import TemplateType, apply_template\nfrom .base import NotifyBase\n\n\nclass NotifyMSTeams(NotifyBase):\n    \"\"\"A wrapper for Microsoft Teams Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"MSTeams\"\n\n    # The services URL\n    service_url = \"https://teams.micrsoft.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"msteams\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/msteams/\"\n\n    # MSTeams uses the http protocol with JSON requests\n    notify_url_v1 = (\n        \"https://outlook.office.com/webhook/\"\n        \"{token_a}/IncomingWebhook/{token_b}/{token_c}\"\n    )\n\n    # New MSTeams webhook (as of April 11th, 2021)\n    notify_url_v2 = (\n        \"https://{team}.webhook.office.com/webhookb2/\"\n        \"{token_a}/IncomingWebhook/{token_b}/{token_c}\"\n    )\n\n    notify_url_v3 = (\n        \"https://{team}.webhook.office.com/webhookb2/\"\n        \"{token_a}/IncomingWebhook/{token_b}/{token_c}/{token_d}\"\n    )\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1000\n\n    # Default Notification Format\n    notify_format = NotifyFormat.MARKDOWN\n\n    # There is no reason we should exceed 35KB when reading in a JSON file.\n    # If it is more than this, then it is not accepted\n    max_msteams_template_size = 35000\n\n    # Define object templates\n    templates = (\n        # New required format\n        \"{schema}://{team}/{token_a}/{token_b}/{token_c}\",\n        # Deprecated\n        \"{schema}://{token_a}/{token_b}/{token_c}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            # The Microsoft Team Name\n            \"team\": {\n                \"name\": _(\"Team Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n            },\n            # Token required as part of the API request\n            #  /AAAAAAAAA@AAAAAAAAA/........./.........\n            \"token_a\": {\n                \"name\": _(\"Token A\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9-]+@[A-Z0-9-]+$\", \"i\"),\n            },\n            # Token required as part of the API request\n            #  /................../BBBBBBBBB/..........\n            \"token_b\": {\n                \"name\": _(\"Token B\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            # Token required as part of the API request\n            #  /........./........./CCCCCCCCCCCCCCCCCCCCCCCC\n            \"token_c\": {\n                \"name\": _(\"Token C\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9-]+$\", \"i\"),\n            },\n            # Token required as part of the API request\n            #  /........./........./........./DDDDDDDDDDDDDDDDD\n            \"token_d\": {\n                \"name\": _(\"Token D\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": False,\n                \"regex\": (r\"^V2[a-zA-Z0-9-_]+$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            \"version\": {\n                \"name\": _(\"Version\"),\n                \"type\": \"choice:int\",\n                \"values\": (1, 2, 3),\n                \"default\": 2,\n            },\n            \"template\": {\n                \"name\": _(\"Template Path\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our token control\n    template_kwargs = {\n        \"tokens\": {\n            \"name\": _(\"Template Tokens\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        token_a,\n        token_b,\n        token_c,\n        token_d=None,\n        team=None,\n        version=None,\n        include_image=True,\n        template=None,\n        tokens=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Microsoft Teams Object.\n\n        You can optional specify a template and identify arguments you\n        wish to populate your template with when posting.  Some reserved\n        template arguments that can not be over-ridden are:\n           `body`, `title`, and `type`.\n        \"\"\"\n        super().__init__(**kwargs)\n\n        try:\n            self.version = int(version)\n\n        except TypeError:\n            # None was specified... take on default\n            self.version = self.template_args[\"version\"][\"default\"]\n\n        except ValueError:\n            # invalid content was provided; let this get caught in the next\n            # validation check for the version\n            self.version = None\n\n        if self.version not in self.template_args[\"version\"][\"values\"]:\n            msg = f\"An invalid MSTeams Version ({version}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.team = validate_regex(team)\n        if not self.team:\n            NotifyBase.logger.deprecate(\n                \"Apprise requires you to identify your Microsoft Team name as \"\n                \"part of the URL. e.g.: \"\n                f\"msteams://TEAM-NAME/{token_a}/{token_b}/{token_c}\"\n            )\n\n            # Fallback\n            self.team = \"outlook\"\n\n        self.token_a = validate_regex(\n            token_a, *self.template_tokens[\"token_a\"][\"regex\"]\n        )\n        if not self.token_a:\n            msg = (\n                f\"An invalid MSTeams (first) Token ({token_a}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.token_b = validate_regex(\n            token_b, *self.template_tokens[\"token_b\"][\"regex\"]\n        )\n        if not self.token_b:\n            msg = (\n                f\"An invalid MSTeams (second) Token ({token_b}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.token_c = validate_regex(\n            token_c, *self.template_tokens[\"token_c\"][\"regex\"]\n        )\n        if not self.token_c:\n            msg = (\n                f\"An invalid MSTeams (third) Token ({token_c}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.token_d = validate_regex(\n            token_d, *self.template_tokens[\"token_d\"][\"regex\"]\n        )\n\n        # Place a thumbnail image inline with the message body\n        self.include_image = include_image\n\n        # Our template object is just an AppriseAttachment object\n        self.template = AppriseAttachment(asset=self.asset)\n        if template:\n            # Add our definition to our template\n            self.template.add(template)\n            # Enforce maximum file size\n            self.template[0].max_file_size = self.max_msteams_template_size\n\n        # Template functionality\n        self.tokens = {}\n        if isinstance(tokens, dict):\n            self.tokens.update(tokens)\n\n        elif tokens:\n            msg = (\n                \"The specified MSTeams Template Tokens \"\n                f\"({tokens}) are not identified as a dictionary.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.logger.deprecate(\n            \"Microsoft is deprecating their MSTeams webhooks on \"\n            \"December 31, 2025. It is advised that you switch to \"\n            \"Microsoft Power Automate (already supported by Apprise as \"\n            \"workflows://. For more information visit: \"\n            \"https://appriseit.com/services/workflows/\"\n        )\n\n    def gen_payload(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"This function generates our payload whether it be the generic one\n        Apprise generates by default, or one provided by a specified external\n        template.\"\"\"\n\n        # Acquire our to-be footer icon if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        if not self.template:\n            # By default we use a generic working payload if there was\n            # no template specified\n            payload = {\n                \"@type\": \"MessageCard\",\n                \"@context\": \"https://schema.org/extensions\",\n                \"summary\": self.app_desc,\n                \"themeColor\": self.color(notify_type),\n                \"sections\": [\n                    {\n                        \"activityImage\": None,\n                        \"activityTitle\": title,\n                        \"text\": body,\n                    },\n                ],\n            }\n\n            if image_url:\n                payload[\"sections\"][0][\"activityImage\"] = image_url\n\n            return payload\n\n        # If our code reaches here, then we generate ourselves the payload\n        template = self.template[0]\n        if not template:\n            # We could not access the attachment\n            self.logger.error(\n                \"Could not access MSTeam template\"\n                f\" {template.url(privacy=True)}.\"\n            )\n            return False\n\n        # Take a copy of our token dictionary\n        tokens = self.tokens.copy()\n\n        # Apply some defaults template values\n        tokens[\"app_body\"] = body\n        tokens[\"app_title\"] = title\n        tokens[\"app_type\"] = notify_type.value\n        tokens[\"app_id\"] = self.app_id\n        tokens[\"app_desc\"] = self.app_desc\n        tokens[\"app_color\"] = self.color(notify_type)\n        tokens[\"app_image_url\"] = image_url\n        tokens[\"app_url\"] = self.app_url\n\n        # Enforce Application mode\n        tokens[\"app_mode\"] = TemplateType.JSON\n\n        try:\n            with open(template.path) as fp:\n                content = json.loads(apply_template(fp.read(), **tokens))\n\n        except OSError:\n            self.logger.error(\n                f\"MSTeam template {template.url(privacy=True)} could not be\"\n                \" read.\"\n            )\n            return None\n\n        except JSONDecodeError as e:\n            self.logger.error(\n                f\"MSTeam template {template.url(privacy=True)} contains\"\n                \" invalid JSON.\"\n            )\n            self.logger.debug(f\"JSONDecodeError: {e}\")\n            return None\n\n        # Load our JSON data (if valid)\n        has_error = False\n        if \"@type\" not in content:\n            self.logger.error(\n                f\"MSTeam template {template.url(privacy=True)} is missing\"\n                \" @type kwarg.\"\n            )\n            has_error = True\n\n        if \"@context\" not in content:\n            self.logger.error(\n                f\"MSTeam template {template.url(privacy=True)} is missing\"\n                \" @context kwarg.\"\n            )\n            has_error = True\n\n        return content if not has_error else None\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Microsoft Teams Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        if self.version == 1:\n            notify_url = self.notify_url_v1.format(\n                token_a=self.token_a,\n                token_b=self.token_b,\n                token_c=self.token_c,\n            )\n\n        if self.version == 2:\n            notify_url = self.notify_url_v2.format(\n                team=self.team,\n                token_a=self.token_a,\n                token_b=self.token_b,\n                token_c=self.token_c,\n            )\n        if self.version == 3:\n            notify_url = self.notify_url_v3.format(\n                team=self.team,\n                token_a=self.token_a,\n                token_b=self.token_b,\n                token_c=self.token_c,\n                token_d=self.token_d,\n            )\n\n        # Generate our payload if it's possible\n        payload = self.gen_payload(\n            body=body, title=title, notify_type=notify_type, **kwargs\n        )\n        if not payload:\n            # No need to present a reason; that will come from the\n            # gen_payload() function itself\n            return False\n\n        self.logger.debug(\n            \"MSTeams POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"MSTeams Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                notify_url,\n                data=json.dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyMSTeams.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send MSTeams notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # We failed\n                return False\n\n            else:\n                self.logger.info(\"Sent MSTeams notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending MSTeams notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # We failed\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.team if self.version > 1 else None,\n            self.token_a,\n            self.token_b,\n            self.token_c,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        if self.version != self.template_args[\"version\"][\"default\"]:\n            params[\"version\"] = str(self.version)\n\n        if self.template:\n            params[\"template\"] = NotifyMSTeams.quote(\n                self.template[0].url(), safe=\"\"\n            )\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n        # Store any template entries if specified\n        params.update({f\":{k}\": v for k, v in self.tokens.items()})\n\n        result = None\n\n        if self.version == 1:\n            result = (\n                \"{schema}://{token_a}/{token_b}/{token_c}/?{params}\".format(\n                    schema=self.secure_protocol,\n                    token_a=self.pprint(self.token_a, privacy, safe=\"@\"),\n                    token_b=self.pprint(self.token_b, privacy, safe=\"\"),\n                    token_c=self.pprint(self.token_c, privacy, safe=\"\"),\n                    params=NotifyMSTeams.urlencode(params),\n                )\n            )\n\n        if self.version == 2:\n            result = (\n                \"{schema}://{team}/{token_a}/{token_b}/{token_c}/\"\n                \"?{params}\".format(\n                    schema=self.secure_protocol,\n                    team=NotifyMSTeams.quote(self.team, safe=\"\"),\n                    token_a=self.pprint(self.token_a, privacy, safe=\"\"),\n                    token_b=self.pprint(self.token_b, privacy, safe=\"\"),\n                    token_c=self.pprint(self.token_c, privacy, safe=\"\"),\n                    params=NotifyMSTeams.urlencode(params),\n                )\n            )\n\n        if self.version == 3:\n            result = (\n                \"{schema}://{team}/{token_a}/{token_b}/{token_c}/\"\n                \"{token_d}/?{params}\".format(\n                    schema=self.secure_protocol,\n                    team=NotifyMSTeams.quote(self.team, safe=\"\"),\n                    token_a=self.pprint(self.token_a, privacy, safe=\"\"),\n                    token_b=self.pprint(self.token_b, privacy, safe=\"\"),\n                    token_c=self.pprint(self.token_c, privacy, safe=\"\"),\n                    token_d=self.pprint(self.token_d, privacy, safe=\"\"),\n                    params=NotifyMSTeams.urlencode(params),\n                )\n            )\n        return result\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get unquoted entries\n        entries = NotifyMSTeams.split_path(results[\"fullpath\"])\n\n        # Deprecated mode (backwards compatibility)\n        if results.get(\"user\"):\n            # If a user was found, it's because it's still part of the first\n            # token, so we concatinate them\n            results[\"token_a\"] = \"{}@{}\".format(\n                NotifyMSTeams.unquote(results[\"user\"]),\n                NotifyMSTeams.unquote(results[\"host\"]),\n            )\n\n        else:\n            # Get the Team from the hostname\n            results[\"team\"] = NotifyMSTeams.unquote(results[\"host\"])\n\n            # Get the token from the path\n            results[\"token_a\"] = (\n                None if not entries else NotifyMSTeams.unquote(entries.pop(0))\n            )\n\n        results[\"token_b\"] = (\n            None if not entries else NotifyMSTeams.unquote(entries.pop(0))\n        )\n        results[\"token_c\"] = (\n            None if not entries else NotifyMSTeams.unquote(entries.pop(0))\n        )\n        results[\"token_d\"] = (\n            None if not entries else NotifyMSTeams.unquote(entries.pop(0))\n        )\n\n        # Get Image\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # Get Team name if defined\n        if \"team\" in results[\"qsd\"] and results[\"qsd\"][\"team\"]:\n            results[\"team\"] = NotifyMSTeams.unquote(results[\"qsd\"][\"team\"])\n\n        # Template Handling\n        if \"template\" in results[\"qsd\"] and results[\"qsd\"][\"template\"]:\n            results[\"template\"] = NotifyMSTeams.unquote(\n                results[\"qsd\"][\"template\"]\n            )\n\n        # Override version if defined\n        if \"version\" in results[\"qsd\"] and results[\"qsd\"][\"version\"]:\n            results[\"version\"] = NotifyMSTeams.unquote(\n                results[\"qsd\"][\"version\"]\n            )\n\n        else:\n            version = 1\n            if results.get(\"team\"):\n                version = 2\n            if results.get(\"token_d\"):\n                version = 3\n            # Set our version if not otherwise set\n            results[\"version\"] = version\n\n        # Store our tokens\n        results[\"tokens\"] = results[\"qsd:\"]\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Legacy Support:\n            https://outlook.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK\n\n        New Hook Support:\n            https://team-name.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK\n\n        Newer Hook Support:\n            https://team-name.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK/V2LMNOP\n        \"\"\"\n\n        # We don't need to do incredibly details token matching as the purpose\n        # of this is just to detect that were dealing with an msteams url\n        # token parsing will occur once we initialize the function\n        result = re.match(\n            r\"^https?://(?P<team>[^.]+)(?P<v2a>\\.webhook)?\\.office\\.com/\"\n            r\"webhook(?P<v2b>b2)?/\"\n            r\"(?P<token_a>[A-Z0-9-]+@[A-Z0-9-]+)/\"\n            r\"IncomingWebhook/\"\n            r\"(?P<token_b>[A-Z0-9]+)/\"\n            r\"(?P<token_c>[A-Z0-9-]+)/\"\n            r\"(?P<token_d>V2[A-Z0-9-_]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            # Version 3 URL\n            return NotifyMSTeams.parse_url(\n                \"{schema}://{team}/{token_a}/{token_b}/{token_c}/{token_d}\"\n                \"/{params}\".format(\n                    schema=NotifyMSTeams.secure_protocol,\n                    team=result.group(\"team\"),\n                    token_a=result.group(\"token_a\"),\n                    token_b=result.group(\"token_b\"),\n                    token_c=result.group(\"token_c\"),\n                    token_d=result.group(\"token_d\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        result = re.match(\n            r\"^https?://(?P<team>[^.]+)(?P<v2a>\\.webhook)?\\.office\\.com/\"\n            r\"webhook(?P<v2b>b2)?/\"\n            r\"(?P<token_a>[A-Z0-9-]+@[A-Z0-9-]+)/\"\n            r\"IncomingWebhook/\"\n            r\"(?P<token_b>[A-Z0-9]+)/\"\n            r\"(?P<token_c>[A-Z0-9-]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            if result.group(\"v2a\"):\n                # Version 2 URL\n                return NotifyMSTeams.parse_url(\n                    \"{schema}://{team}/{token_a}/{token_b}/{token_c}\"\n                    \"/{params}\".format(\n                        schema=NotifyMSTeams.secure_protocol,\n                        team=result.group(\"team\"),\n                        token_a=result.group(\"token_a\"),\n                        token_b=result.group(\"token_b\"),\n                        token_c=result.group(\"token_c\"),\n                        params=(\n                            \"\"\n                            if not result.group(\"params\")\n                            else result.group(\"params\")\n                        ),\n                    )\n                )\n            else:\n                # Version 1 URLs\n                # team is also set to 'outlook' in this case\n                return NotifyMSTeams.parse_url(\n                    \"{schema}://{token_a}/{token_b}/{token_c}/{params}\".format(\n                        schema=NotifyMSTeams.secure_protocol,\n                        token_a=result.group(\"token_a\"),\n                        token_b=result.group(\"token_b\"),\n                        token_c=result.group(\"token_c\"),\n                        params=(\n                            \"\"\n                            if not result.group(\"params\")\n                            else result.group(\"params\")\n                        ),\n                    )\n                )\n        return None\n"
  },
  {
    "path": "apprise/plugins/nextcloud.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom itertools import chain\nfrom json import loads\nimport re\n\nimport requests\n\nfrom ..common import NotifyType, PersistentStoreMode\nfrom ..exception import AppriseException\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_list\nfrom .base import NotifyBase\n\n# Is Group Detection\nIS_GROUP = re.compile(\n    r\"^\\s*((#|%23)(?P<group>[a-z0-9_-]+)|\"\n    r\"((#|%23)?(?P<all>all|everyone|\\*)))\\s*$\",\n    re.I,\n)\n\n# Is User Detection\nIS_USER = re.compile(\n    r\"^\\s*(@|%40)?(?P<user>[a-z0-9_-]+)\\s*$\",\n    re.I,\n)\n\n\nclass NextcloudGroupDiscoveryException(AppriseException):\n    \"\"\"Apprise Nextcloud Group Discovery Exception Class.\"\"\"\n\n\nclass NotifyNextcloud(NotifyBase):\n    \"\"\"A wrapper for Nextcloud Notifications.\n\n    Targets can be individual users, groups, or everyone:\n    - user: specify one or more usernames as path segments\n    - group: prefix with a hash (e.g., ``#DevTeam``)\n    - everyone: use ``all`` (aliases: ``everyone``, ``*``)\n\n    Group and everyone expansion uses Nextcloud's OCS provisioning API and\n    requires appropriate permissions (typically an admin account) and the\n    provisioning API enabled on the server.\n    \"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Nextcloud\"\n\n    # The services URL\n    service_url = \"https://nextcloud.com/\"\n\n    # Insecure protocol (for those self hosted requests)\n    protocol = \"ncloud\"\n\n    # The default protocol (this is secure for notica)\n    secure_protocol = \"nclouds\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/nextcloud/\"\n\n    # Nextcloud title length\n    title_maxlen = 255\n\n    # Defines the maximum allowable characters per message.\n    body_maxlen = 4000\n\n    # Our default is to not use persistent storage beyond in-memory\n    # reference\n    storage_mode = PersistentStoreMode.AUTO\n\n    # Defines how long we cache our discovery for\n    group_discovery_cache_length_sec = 86400\n\n    # unique identifier to cache the 'all' group category\n    all_group_id = \"all\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{targets}\",\n        \"{schema}://{host}:{port}/{targets}\",\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n                \"prefix\": \"@\",\n            },\n            \"target_group\": {\n                \"name\": _(\"Target Group\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n                \"prefix\": \"#\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            # Nextcloud uses different API end points depending on the version\n            # being used however the (API) payload remains the same.  Allow\n            # users to specify the version they are using:\n            \"version\": {\n                \"name\": _(\"Version\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"default\": 21,\n            },\n            \"url_prefix\": {\n                \"name\": _(\"URL Prefix\"),\n                \"type\": \"string\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self,\n        targets=None,\n        version=None,\n        headers=None,\n        url_prefix=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Nextcloud Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Store our targets\n        self.targets = []\n        self.groups = set()\n\n        for target in parse_list(targets):\n            results = IS_GROUP.match(target)\n            if results:\n                group_id = (\n                    self.all_group_id\n                    if results.group(\"all\") else results.group(\"group\"))\n\n                self.groups.add(group_id)\n                self.logger.debug(\"Added Nextcloud group '%s'\", group_id)\n                continue\n\n            results = IS_USER.match(target)\n            if results:\n                # Store our target\n                self.targets.append(results.group(\"user\"))\n                self.logger.debug(\n                    \"Added Nextcloud user '%s'\", self.targets[-1])\n                continue\n\n            self.logger.warning(\n                \"Ignored invalid Nextcloud user/group '%s'\", target)\n\n        self.version = self.template_args[\"version\"][\"default\"]\n        if version is not None:\n            try:\n                self.version = int(version)\n                if self.version < self.template_args[\"version\"][\"min\"]:\n                    # Let upper exception handle this\n                    raise ValueError()\n\n            except (ValueError, TypeError):\n                msg = (\n                    f\"At invalid Nextcloud version ({version}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n        # Support URL Prefix\n        self.url_prefix = \"\" if not url_prefix else (\n            \"/\" + url_prefix.strip(\"/\"))\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        return\n\n    def _fetch(self, payload=None, target=None, group=None):\n        \"\"\"Wrapper to NextCloud API requests object.\"\"\"\n\n        # our method\n        method = \"POST\" if target else \"GET\"\n\n        # Prepare our Header\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"OCS-APIREQUEST\": \"true\",\n            \"Accept\": \"application/json\",\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        # Prepare base URL fragments\n        scheme = \"https\" if self.secure else \"http\"\n        host_port = (\n            self.host\n            if not isinstance(self.port, int)\n            else f\"{self.host}:{self.port}\"\n        )\n        base = f\"{scheme}://{host_port}\"\n        if self.url_prefix:\n            base = f\"{base}{self.url_prefix}\"\n\n        # Auth\n        auth = (self.user, self.password) if self.user else None\n\n        # our URL Parameters\n        params = {}\n\n        # our response\n        content = None\n\n        if target:\n            # Nextcloud URL based on version used\n            query = f'v{self.version} Notify \"{target}\"'\n            esc_target = NotifyNextcloud.quote(target)\n            url = (\n                f\"{base}/ocs/v2.php/\"\n                \"apps/admin_notifications/\"\n                f\"api/v1/notifications/{esc_target}\"\n                if self.version < 21\n                else (\n                    f\"{base}/ocs/v2.php/\"\n                    \"apps/notifications/\"\n                    f\"api/v2/admin_notifications/{esc_target}\"\n                )\n            )\n\n        elif group:\n            query = f'Group \"{group}\"'\n            params = {\n                \"format\": \"json\",\n            }\n            esc_group = NotifyNextcloud.quote(group)\n            url = f\"{base}/ocs/v1.php/cloud/groups/{esc_group}\"\n\n        else:  # Users\n            query = \"Users\"\n            params = {\n                \"format\": \"json\",\n            }\n            url = f\"{base}/ocs/v1.php/cloud/users\"\n\n        self.throttle()\n        self.logger.debug(\n            \"Nextcloud %s %s URL: %s (cert_verify=%r)\",\n            query,\n            method,\n            url,\n            self.verify_certificate,\n        )\n\n        if payload:\n            self.logger.debug(\n                \"Nextcloud v%d Payload: %s\", self.version, payload\n            )\n\n        try:\n            # Prepare our request object\n            request = requests.post if target else requests.get\n\n            r = request(\n                url,\n                headers=headers,\n                data=payload,\n                params=params,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                content = loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                content = {}\n\n            if r.status_code != requests.codes.ok:\n                status_str = NotifyNextcloud.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Nextcloud %s: %s%serror=%d.\",\n                    query,\n                    status_str,\n                    \", \" if status_str else \"\",\n                    r.status_code,\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                if target:\n                    return (False, content)\n\n                raise NextcloudGroupDiscoveryException(\n                    f\"{query} non-200 response\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred with Nextcloud %s\",\n                query,\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            if target:\n                return (False, None)\n            raise NextcloudGroupDiscoveryException(\n                f\"{query} socket exception\") from None\n\n        self.logger.info(\"Sent Nextcloud %s\", query)\n        return (True, content)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Nextcloud Notification.\"\"\"\n\n        # Create a copy of our targets\n        targets = set(self.targets)\n\n        # Initialize our has_error flag\n        has_error = False\n\n        if self.groups:\n            # Append our group lookup\n            try:\n                for group in self.groups:\n                    if group == self.all_group_id:\n                        targets |= self.all_users()\n\n                    else:\n                        # specific group\n                        targets |= self.users_by_group(group)\n\n            except NextcloudGroupDiscoveryException:\n                # logging already handled within all_users and user_by_group()\n                return False\n\n        if not targets:\n            # There were no services to notify\n            self.logger.warning(\"There were no Nextcloud targets to notify.\")\n            return False\n\n        for target in targets:\n            # Prepare our Payload\n            payload = {\n                \"shortMessage\": title if title else self.app_desc,\n            }\n            if body:\n                # Only store the longMessage if a body was defined; nextcloud\n                # doesn't take kindly to empty longMessage entries.\n                payload[\"longMessage\"] = body\n\n            is_okay, _ = self._fetch(payload, target)\n            if not is_okay:\n                # Toggle our status\n                has_error = True\n\n        return not has_error\n\n    def users_by_group(self, group):\n        \"\"\"\n        Lists users associated with a provided group\n        \"\"\"\n\n        # Check our cache\n        targets = self.store.get(group)\n        if targets is not None:\n            # Returned cached value\n            self.logger.trace(\n                f\"Using Nextcloud cached response for group '{group}' \"\n                \"query\")\n            return set(targets)\n\n        # _fetch throws an exception if it fails, so we can\n        # go ahead and ignore checking for it.\n        _, response = self._fetch(group=group)\n\n        # Initialize our targets\n        targets = set()\n\n        # If we get here, our fetch was successful; look up our users\n        users = response.get(\"ocs\", {}).get(\"data\", {}).get(\"users\")\n        if isinstance(users, list):\n            targets = {\n                s for u in users if (s := str(u).strip())\n            }\n\n        if not targets:\n            self.logger.warning(\n                \"No users associated with Nextcloud group '%s'\", group\n            )\n\n        self.store.set(\n            group, list(targets),\n            expires=self.group_discovery_cache_length_sec)\n        return targets\n\n    def all_users(self):\n        \"\"\"\n        Lists users associated with Nextcloud instance\n        \"\"\"\n        # Check our cache\n        targets = self.store.get(self.all_group_id)\n        if targets is not None:\n            self.logger.trace(\n                \"Using Nextcloud cached response for all-user query\")\n            return set(targets)\n\n        # _fetch throws an exception if it fails, so we can\n        # go ahead and ignore checking for it.\n        _, response = self._fetch()\n\n        # Initialize our targets\n        targets = set()\n\n        # If we get here, our fetch was successful; look up our users\n        users = response.get(\"ocs\", {}).get(\"data\", {}).get(\"users\")\n        if isinstance(users, list):\n            targets = {\n                s for u in users if (s := str(u).strip())\n            }\n\n        if not targets:\n            self.logger.warning(\n                \"Failed to retrieve all users from Nextcloud\",\n            )\n            # early exit; no cache\n            return targets\n\n        self.store.set(\n            self.all_group_id,\n            list(targets), expires=self.group_discovery_cache_length_sec)\n        return targets\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n            self.url_prefix,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Create URL parameters from our headers\n        params = {f\"+{k}\": v for k, v in self.headers.items()}\n\n        # Set our version\n        params[\"version\"] = str(self.version)\n\n        if self.url_prefix.rstrip(\"/\"):\n            params[\"url_prefix\"] = self.url_prefix\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyNextcloud.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyNextcloud.quote(self.user, safe=\"\"),\n            )\n\n        group_prefix = self.template_tokens[\"target_group\"][\"prefix\"]\n        user_prefix = self.template_tokens[\"target_user\"][\"prefix\"]\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a\n            # valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            targets=\"/\".join([\n                NotifyNextcloud.quote(x, safe=(group_prefix + user_prefix))\n                for x in chain(\n                    # Groups are prefixed with a pound/hashtag symbol\n                    [f\"{group_prefix}{x}\" for x in self.groups],\n                    # Users\n                    [f\"{user_prefix}{x}\" for x in self.targets],\n                )\n            ]),\n\n            params=NotifyNextcloud.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets) + len(self.groups)\n        return max(1, targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Fetch our targets\n        results[\"targets\"] = NotifyNextcloud.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyNextcloud.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Allow users to over-ride the Nextcloud version being used\n        if \"version\" in results[\"qsd\"] and len(results[\"qsd\"][\"version\"]):\n            results[\"version\"] = NotifyNextcloud.unquote(\n                results[\"qsd\"][\"version\"]\n            )\n\n        # Support URL Prefixes\n        if \"url_prefix\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"url_prefix\"]\n        ):\n            results[\"url_prefix\"] = NotifyNextcloud.unquote(\n                results[\"qsd\"][\"url_prefix\"]\n            )\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyNextcloud.unquote(x): NotifyNextcloud.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/nextcloudtalk.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_list\nfrom .base import NotifyBase\n\n\nclass NotifyNextcloudTalk(NotifyBase):\n    \"\"\"A wrapper for Nextcloud Talk Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Nextcloud Talk\")\n\n    # The services URL\n    service_url = \"https://nextcloud.com/talk\"\n\n    # Insecure protocol (for those self hosted requests)\n    protocol = \"nctalk\"\n\n    # The default protocol (this is secure for notica)\n    secure_protocol = \"nctalks\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/nextcloudtalk/\"\n\n    # Nextcloud title length\n    title_maxlen = 255\n\n    # Defines the maximum allowable characters per message.\n    body_maxlen = 4000\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_room_id\": {\n                \"name\": _(\"Room ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"url_prefix\": {\n                \"name\": _(\"URL Prefix\"),\n                \"type\": \"string\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(self, targets=None, headers=None, url_prefix=None, **kwargs):\n        \"\"\"Initialize Nextcloud Talk Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if self.user is None or self.password is None:\n            msg = \"A NextCloudTalk User and Password must be specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our targets\n        self.targets = parse_list(targets)\n\n        # Support URL Prefix\n        self.url_prefix = \"\" if not url_prefix else url_prefix.strip(\"/\")\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Nextcloud Talk Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\n                \"There were no Nextcloud Talk targets to notify.\"\n            )\n            return False\n\n        # Prepare our Header\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"OCS-APIRequest\": \"true\",\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n        while len(targets):\n            target = targets.pop(0)\n\n            # Prepare our Payload\n            if not body:\n                payload = {\n                    \"message\": title if title else self.app_desc,\n                }\n            else:\n                payload = {\n                    \"message\": (\n                        title + \"\\r\\n\" + body\n                        if title\n                        else self.app_desc + \"\\r\\n\" + body\n                    ),\n                }\n\n            # Nextcloud Talk URL\n            notify_url = (\n                \"{schema}://{host}/{url_prefix}\"\n                f\"/ocs/v2.php/apps/spreed/api/v1/chat/{target}\"\n            )\n\n            notify_url = notify_url.format(\n                schema=\"https\" if self.secure else \"http\",\n                host=(\n                    self.host\n                    if not isinstance(self.port, int)\n                    else f\"{self.host}:{self.port}\"\n                ),\n                url_prefix=self.url_prefix,\n                target=target,\n            )\n\n            self.logger.debug(\n                \"Nextcloud Talk POST URL: %s (cert_verify=%r)\",\n                notify_url,\n                self.verify_certificate,\n            )\n            self.logger.debug(\"Nextcloud Talk Payload: %s\", payload)\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    auth=(self.user, self.password),\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.created,\n                    requests.codes.ok,\n                ):\n                    # We had a problem\n                    status_str = NotifyNextcloudTalk.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Nextcloud Talk notification:\"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # track our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\"Sent Nextcloud Talk notification.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Nextcloud Talk \"\n                    \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # track our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our default set of parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n        if self.url_prefix:\n            params[\"url_prefix\"] = self.url_prefix\n\n        # Determine Authentication\n        auth = \"{user}:{password}@\".format(\n            user=NotifyNextcloudTalk.quote(self.user, safe=\"\"),\n            password=self.pprint(\n                self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n        )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a\n            # valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            targets=\"/\".join(\n                [NotifyNextcloudTalk.quote(x) for x in self.targets]\n            ),\n            params=NotifyNextcloudTalk.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Fetch our targets\n        results[\"targets\"] = NotifyNextcloudTalk.split_path(\n            results[\"fullpath\"]\n        )\n\n        # Support URL Prefixes\n        if \"url_prefix\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"url_prefix\"]\n        ):\n            results[\"url_prefix\"] = NotifyNextcloudTalk.unquote(\n                results[\"qsd\"][\"url_prefix\"]\n            )\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyNextcloudTalk.unquote(x): NotifyNextcloudTalk.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/notica.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# 1. Simply visit https://notica.us\n# 2. You'll be provided a new variation of the website which will look\n#    something like: https://notica.us/?abc123.\n#                                         ^\n#                                         |\n#                                       token\n#\n#    Your token is actually abc123 (do not include/grab the question mark)\n#    You can use that URL as is directly in Apprise, or you can follow\n#    the next step which shows you how to assemble the Apprise URL:\n#\n# 3. With respect to the above, your apprise URL would be:\n#       notica://abc123\n#\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NoticaMode:\n    \"\"\"Tracks if we're accessing the notica upstream server or a locally hosted\n    one.\"\"\"\n\n    # We're dealing with a self hosted service\n    SELFHOSTED = \"selfhosted\"\n\n    # We're dealing with the official hosted service at https://notica.us\n    OFFICIAL = \"official\"\n\n\n# Define our Notica Modes\nNOTICA_MODES = (\n    NoticaMode.SELFHOSTED,\n    NoticaMode.OFFICIAL,\n)\n\n\nclass NotifyNotica(NotifyBase):\n    \"\"\"A wrapper for Notica Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Notica\"\n\n    # The services URL\n    service_url = \"https://notica.us/\"\n\n    # Insecure protocol (for those self hosted requests)\n    protocol = \"notica\"\n\n    # The default protocol (this is secure for notica)\n    secure_protocol = \"noticas\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/notica/\"\n\n    # Notica URL\n    notify_url = \"https://notica.us/?{token}\"\n\n    # Notica does not support a title\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{token}\",\n        # Self-hosted notica servers\n        \"{schema}://{host}/{token}\",\n        \"{schema}://{host}:{port}/{token}\",\n        \"{schema}://{user}@{host}/{token}\",\n        \"{schema}://{user}@{host}:{port}/{token}\",\n        \"{schema}://{user}:{password}@{host}/{token}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{token}\",\n        # Self-hosted notica servers (with custom path)\n        \"{schema}://{host}{path}/{token}\",\n        \"{schema}://{host}:{port}/{path}/{token}\",\n        \"{schema}://{user}@{host}/{path}/{token}\",\n        \"{schema}://{user}@{host}:{port}{path}/{token}\",\n        \"{schema}://{user}:{password}@{host}{path}/{token}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{path}/{token}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": r\"^\\?*(?P<token>[^/]+)\\s*$\",\n            },\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"path\": {\n                \"name\": _(\"Path\"),\n                \"type\": \"string\",\n                \"map_to\": \"fullpath\",\n                \"default\": \"/\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(self, token, headers=None, **kwargs):\n        \"\"\"Initialize Notica Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Token (associated with project)\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = f\"An invalid Notica Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Setup our mode\n        self.mode = NoticaMode.SELFHOSTED if self.host else NoticaMode.OFFICIAL\n\n        # prepare our fullpath\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"/\"\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Notica Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # Prepare our payload\n        payload = f\"d:{body}\"\n\n        # Auth is used for SELFHOSTED queries\n        auth = None\n\n        if self.mode is NoticaMode.OFFICIAL:\n            # prepare our notify url\n            notify_url = self.notify_url.format(token=self.token)\n\n        else:\n            # Prepare our self hosted URL\n\n            # Apply any/all header over-rides defined\n            headers.update(self.headers)\n\n            if self.user:\n                auth = (self.user, self.password)\n\n            # Set our schema\n            schema = \"https\" if self.secure else \"http\"\n\n            # Prepare our notify_url\n            notify_url = f\"{schema}://{self.host}\"\n            if isinstance(self.port, int):\n                notify_url += f\":{self.port}\"\n\n            notify_url += f\"{self.fullpath}?token={self.token}\"\n\n        self.logger.debug(\n            \"Notica POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Notica Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url.format(token=self.token),\n                data=payload,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyNotica.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Notica notification:{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Notica notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Notica notification.\",\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.mode,\n            self.token,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if self.mode == NoticaMode.OFFICIAL:\n            # Official URLs are easy to assemble\n            return \"{schema}://{token}/?{params}\".format(\n                schema=self.protocol,\n                token=self.pprint(self.token, privacy, safe=\"\"),\n                params=NotifyNotica.urlencode(params),\n            )\n\n        # If we reach here then we are assembling a self hosted URL\n\n        # Append URL parameters from our headers\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Authorization can be used for self-hosted sollutions\n        auth = \"\"\n\n        # Determine Authentication\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyNotica.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifyNotica.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return (\n            \"{schema}://{auth}{hostname}{port}{fullpath}{token}/?{params}\"\n            .format(\n                schema=self.secure_protocol if self.secure else self.protocol,\n                auth=auth,\n                hostname=NotifyNotica.quote(self.host, safe=\"\"),\n                port=(\n                    \"\"\n                    if self.port is None or self.port == default_port\n                    else f\":{self.port}\"\n                ),\n                fullpath=NotifyNotica.quote(self.fullpath, safe=\"/\"),\n                token=self.pprint(self.token, privacy, safe=\"\"),\n                params=NotifyNotica.urlencode(params),\n            )\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get unquoted entries\n        entries = NotifyNotica.split_path(results[\"fullpath\"])\n        if not entries:\n            # If there are no path entries, then we're only dealing with the\n            # official website\n            results[\"mode\"] = NoticaMode.OFFICIAL\n\n            # Store our token using the host\n            results[\"token\"] = NotifyNotica.unquote(results[\"host\"])\n\n            # Unset our host\n            results[\"host\"] = None\n\n        else:\n            # Otherwise we're running a self hosted instance\n            results[\"mode\"] = NoticaMode.SELFHOSTED\n\n            # The last element in the list is our token\n            results[\"token\"] = entries.pop()\n\n            # Re-assemble our full path\n            results[\"fullpath\"] = (\n                \"/\" if not entries else \"/{}/\".format(\"/\".join(entries))\n            )\n\n            # Add our headers that the user can potentially over-ride if they\n            # wish to to our returned result set and tidy entries by unquoting\n            # them\n            results[\"headers\"] = {\n                NotifyNotica.unquote(x): NotifyNotica.unquote(y)\n                for x, y in results[\"qsd+\"].items()\n            }\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://notica.us/?abc123\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://notica\\.us/?\"\n            r\"\\??(?P<token>[^&]+)([&\\s]*(?P<params>.+))?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyNotica.parse_url(\n                \"{schema}://{token}/{params}\".format(\n                    schema=NotifyNotica.protocol,\n                    token=result.group(\"token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else \"?{}\".format(result.group(\"params\"))\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/notifiarr.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom itertools import chain\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\nfrom .discord import USER_ROLE_DETECTION_RE\n\n# Used to break path apart into list of channels\nCHANNEL_LIST_DELIM = re.compile(r\"[ \\t\\r\\n,#\\\\/]+\")\n\nCHANNEL_REGEX = re.compile(r\"^\\s*(\\#|\\%35)?(?P<channel>[0-9]+)\", re.I)\n\n# For API Details see:\n# https://notifiarr.wiki/Client/Installation\n\n# Another good example:\n# https://notifiarr.wiki/en/Website/ \\\n#              Integrations/Passthrough#payload-example-1\n\n\nclass NotifyNotifiarr(NotifyBase):\n    \"\"\"A wrapper for Notifiarr Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Notifiarr\"\n\n    # The services URL\n    service_url = \"https://notifiarr.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"notifiarr\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/notifiarr/\"\n\n    # The Notification URL\n    notify_url = \"https://notifiarr.com/api/v1/notification/apprise\"\n\n    # Notifiarr Throttling (knowing in advance reduces 429 responses)\n    # define('NOTIFICATION_LIMIT_SECOND_USER', 5);\n    # define('NOTIFICATION_LIMIT_SECOND_PATRON', 15);\n\n    # Throttle requests ever so slightly\n    request_rate_per_sec = 0.04\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_256\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}/{targets}\",)\n\n    # Define our apikeys; these are the minimum apikeys required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"key\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"apikey\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"event\": {\n                \"name\": _(\"Discord Event ID\"),\n                \"type\": \"int\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            \"source\": {\n                \"name\": _(\"Source\"),\n                \"type\": \"string\",\n            },\n            \"from\": {\"alias_of\": \"source\"},\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        apikey=None,\n        include_image=None,\n        event=None,\n        targets=None,\n        source=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notifiarr Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.apikey = apikey\n        if not self.apikey:\n            msg = f\"An invalid Notifiarr APIKey ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Place a thumbnail image inline with the message body\n        self.include_image = (\n            include_image\n            if isinstance(include_image, bool)\n            else self.template_args[\"image\"][\"default\"]\n        )\n\n        # Prepare our source (if set)\n        self.source = validate_regex(source)\n\n        self.event = 0\n        if event:\n            try:\n                self.event = int(event)\n\n            except (ValueError, TypeError):\n                msg = (\n                    \"An invalid Notifiarr Discord Event ID \"\n                    f\"({event}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n        # Prepare our targets\n        self.targets = {\n            \"channels\": [],\n            \"invalid\": [],\n        }\n\n        for target in parse_list(targets):\n            result = CHANNEL_REGEX.match(target)\n            if result:\n                # Store role information\n                self.targets[\"channels\"].append(int(result.group(\"channel\")))\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid channel ({target}) specified.\",\n            )\n            self.targets[\"invalid\"].append(target)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.apikey,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n\n        if self.source:\n            params[\"source\"] = self.source\n\n        if self.event:\n            params[\"event\"] = self.event\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{apikey}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join([\n                NotifyNotifiarr.quote(x, safe=\"+#@\")\n                for x in chain(\n                    # Channels\n                    [f\"#{x}\" for x in self.targets[\"channels\"]],\n                    # Pass along the same invalid entries as were provided\n                    self.targets[\"invalid\"],\n                )\n            ]),\n            params=NotifyNotifiarr.urlencode(params),\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Notifiarr Notification.\"\"\"\n\n        if not self.targets[\"channels\"]:\n            # There were no services to notify\n            self.logger.warning(\"There were no Notifiarr channels to notify.\")\n            return False\n\n        # No error to start with\n        has_error = False\n\n        # Acquire image_url\n        image_url = self.image_url(notify_type)\n\n        # Define our mentions\n        mentions = {\n            \"pingUser\": [],\n            \"pingRole\": [],\n            \"content\": [],\n        }\n\n        # parse for user id's <@123> and role IDs <@&456>\n        results = USER_ROLE_DETECTION_RE.findall(body)\n        if results:\n            for is_role, no, value in results:\n                if value:\n                    # @everybody, @admin, etc - unsupported\n                    mentions[\"content\"].append(f\"@{value}\")\n\n                elif is_role:\n                    mentions[\"pingRole\"].append(no)\n                    mentions[\"content\"].append(f\"<@&{no}>\")\n\n                else:  # is_user\n                    mentions[\"pingUser\"].append(no)\n                    mentions[\"content\"].append(f\"<@{no}>\")\n\n        for _idx, channel in enumerate(self.targets[\"channels\"]):\n            # prepare Notifiarr Object\n            payload = {\n                \"source\": self.source if self.source else self.app_id,\n                \"type\": notify_type.value,\n                \"notification\": {\n                    \"update\": bool(self.event),\n                    \"name\": self.app_id,\n                    \"event\": str(self.event) if self.event else \"\",\n                },\n                \"discord\": {\n                    \"color\": self.color(notify_type),\n                    \"ping\": {\n                        # Only 1 user is supported, so truncate the rest\n                        \"pingUser\": (\n                            0\n                            if not mentions[\"pingUser\"]\n                            else mentions[\"pingUser\"][0]\n                        ),\n                        # Only 1 role is supported, so truncate the rest\n                        \"pingRole\": (\n                            0\n                            if not mentions[\"pingRole\"]\n                            else mentions[\"pingRole\"][0]\n                        ),\n                    },\n                    \"text\": {\n                        \"title\": title,\n                        \"content\": (\n                            \"\"\n                            if not mentions[\"content\"]\n                            else \"👉 \" + \" \".join(mentions[\"content\"])\n                        ),\n                        \"description\": body,\n                        \"footer\": self.app_desc,\n                    },\n                    \"ids\": {\n                        \"channel\": channel,\n                    },\n                },\n            }\n\n            if self.include_image and image_url:\n                payload[\"discord\"][\"text\"][\"icon\"] = image_url\n                payload[\"discord\"][\"images\"] = {\n                    \"thumbnail\": image_url,\n                }\n\n            if not self._send(payload):\n                has_error = True\n\n        return not has_error\n\n    def _send(self, payload):\n        \"\"\"Send notification.\"\"\"\n        self.logger.debug(\n            \"Notifiarr POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Notifiarr Payload: {payload!s}\")\n\n        # Prepare HTTP Headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"text/plain\",\n            \"X-api-Key\": self.apikey,\n        }\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code < 200 or r.status_code >= 300:\n                # We had a problem\n                status_str = NotifyNotifiarr.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Notifiarr %s notification: %serror=%s.\",\n                    status_str,\n                    \", \" if status_str else \"\",\n                    r.status_code,\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Notifiarr notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Notifiarr \"\n                f\"Chat notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets[\"channels\"]) + len(self.targets[\"invalid\"])\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get channels\n        results[\"targets\"] = NotifyNotifiarr.split_path(results[\"fullpath\"])\n\n        if \"event\" in results[\"qsd\"] and len(results[\"qsd\"][\"event\"]):\n            results[\"event\"] = NotifyNotifiarr.unquote(results[\"qsd\"][\"event\"])\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", False)\n        )\n\n        # Track if we need to extract the hostname as a target\n        host_is_potential_target = False\n\n        if \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifyNotifiarr.unquote(\n                results[\"qsd\"][\"source\"]\n            )\n\n        elif \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyNotifiarr.unquote(results[\"qsd\"][\"from\"])\n\n        # Set our apikey if found as an argument\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            results[\"apikey\"] = NotifyNotifiarr.unquote(\n                results[\"qsd\"][\"apikey\"]\n            )\n\n            host_is_potential_target = True\n\n        elif \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            results[\"apikey\"] = NotifyNotifiarr.unquote(results[\"qsd\"][\"key\"])\n\n            host_is_potential_target = True\n\n        else:\n            # Pop the first element (this is the api key)\n            results[\"apikey\"] = NotifyNotifiarr.unquote(results[\"host\"])\n\n        if host_is_potential_target is True and results[\"host\"]:\n            results[\"targets\"].append(NotifyNotifiarr.unquote(results[\"host\"]))\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += list(\n                filter(\n                    bool,\n                    CHANNEL_LIST_DELIM.split(\n                        NotifyNotifiarr.unquote(results[\"qsd\"][\"to\"])\n                    ),\n                )\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/notificationapi.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Simple API Reference:\n#  - https://www.notificationapi.com/docs/reference/server#send\n\nfrom __future__ import annotations\n\nimport base64\nfrom email.utils import formataddr\nfrom itertools import chain\nfrom json import dumps, loads\nimport re\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..conversion import convert_between\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_email,\n    is_phone_no,\n    parse_emails,\n    parse_list,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n# Used to detect ID\nIS_VALID_ID_RE = re.compile(\n    r\"^\\s*(@|%40)?(?P<id>[\\w_-]+)\\s*$\", re.I)\n\n\nclass NotificationAPIRegion:\n    \"\"\"Regions.\"\"\"\n\n    CA = \"ca\"\n    US = \"us\"\n    EU = \"eu\"\n\n\n# NotificationAPI endpoints\nNOTIFICATIONAPI_API_LOOKUP = {\n    NotificationAPIRegion.US: \"https://api.notificationapi.com\",\n    NotificationAPIRegion.CA: \"https://api.ca.notificationapi.com\",\n    NotificationAPIRegion.EU: \"https://api.eu.notificationapi.com\",\n}\n\n# A List of our regions we can use for verification\nNOTIFICATIONAPI_REGIONS = (\n    NotificationAPIRegion.US,\n    NotificationAPIRegion.CA,\n    NotificationAPIRegion.EU,\n)\n\n\nclass NotificationAPIChannel:\n    \"\"\"Channels\"\"\"\n\n    EMAIL = \"email\"\n    SMS = \"sms\"\n    INAPP = \"inapp\"\n    WEB_PUSH = \"web_push\"\n    MOBILE_PUSH = \"mobile_push\"\n    SLACK = \"slack\"\n\n\n# A List of our channels we can use for verification\nNOTIFICATIONAPI_CHANNELS: frozenset[str] = frozenset([\n    NotificationAPIChannel.EMAIL,\n    NotificationAPIChannel.SMS,\n    NotificationAPIChannel.INAPP,\n    NotificationAPIChannel.WEB_PUSH,\n    NotificationAPIChannel.MOBILE_PUSH,\n    NotificationAPIChannel.SLACK,\n])\n\n\nclass NotificationAPIMode:\n    \"\"\"Modes\"\"\"\n\n    TEMPLATE = \"template\"\n    MESSAGE = \"message\"\n\n\n# A List of our channels we can use for verification\nNOTIFICATIONAPI_MODES: frozenset[str] = frozenset([\n    NotificationAPIMode.TEMPLATE,\n    NotificationAPIMode.MESSAGE,\n])\n\n\nclass NotifyNotificationAPI(NotifyBase):\n    \"\"\"\n    A wrapper for NotificationAPI Notifications\n    \"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"NotificationAPI\"\n\n    # The services URL\n    service_url = \"https://www.notificationapi.com/\"\n\n    # The default secure protocol\n    secure_protocol = (\"napi\", \"notificationapi\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/notificationapi/\"\n\n    # If no NotificationAPI Message Type is specified, then the following is\n    # used\n    default_message_type = \"apprise\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.2\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # Define object templates\n    templates = (\n        \"{schema}://{client_id}/{client_secret}/{targets}\",\n        \"{schema}://{type}@{client_id}/{client_secret}/{targets}\",\n    )\n\n    # Explicit URL tokens we care about (all others from base are ignored)\n    template_tokens = dict(NotifyBase.template_tokens, **{\n        \"type\": {\n            \"name\": _(\"Message Type\"),\n            \"type\": \"string\",\n            \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n            \"required\": True,\n            \"map_to\": \"message_type\",\n        },\n        \"client_id\": {\n            \"name\": _(\"Client ID\"),\n            \"type\": \"string\",\n            \"required\": True,\n        },\n        \"client_secret\": {\n            \"name\": _(\"Client Secret\"),\n            \"type\": \"string\",\n            \"required\": True,\n            \"private\": True,\n        },\n        \"target_email\": {\n            \"name\": _(\"Target Email\"),\n            \"type\": \"string\",\n            \"map_to\": \"targets\",\n        },\n        \"target_id\": {\n            \"name\": _(\"Target ID\"),\n            \"type\": \"string\",\n            \"map_to\": \"targets\",\n        },\n        \"target_sms\": {\n            \"name\": _(\"Target SMS\"),\n            \"type\": \"string\",\n            \"map_to\": \"targets\",\n        },\n        \"targets\": {\n            \"name\": _(\"Targets\"),\n            \"type\": \"list:string\",\n            \"required\": True,\n        },\n    })\n\n    # Supported query args\n    template_args = dict(NotifyBase.template_args, **{\n        \"type\": {\n            \"alias_of\": \"type\",\n        },\n        \"channels\": {\n            \"name\": _(\"Channels\"),\n            \"type\": \"list:string\",\n            \"values\": NOTIFICATIONAPI_CHANNELS,\n        },\n        \"region\": {\n            \"name\": _(\"Region Name\"),\n            \"type\": \"choice:string\",\n            \"values\": NOTIFICATIONAPI_REGIONS,\n            \"default\": NotificationAPIRegion.US,\n        },\n        \"mode\": {\n            \"name\": _(\"Mode\"),\n            \"type\": \"choice:string\",\n            \"values\": NOTIFICATIONAPI_MODES,\n        },\n        \"reply\": {\n            \"name\": _(\"Reply To\"),\n            \"type\": \"string\",\n            \"map_to\": \"reply_to\",\n        },\n        \"from\": {\n            \"name\": _(\"From Email\"),\n            \"type\": \"string\",\n            \"map_to\": \"from_addr\",\n        },\n        \"id\": {\n            \"alias_of\": \"client_id\",\n        },\n        \"secret\": {\n            \"alias_of\": \"client_secret\",\n        },\n        \"to\": {\n            \"alias_of\": \"targets\",\n        },\n        # Email Values\n        \"cc\": {\n            \"name\": _(\"Carbon Copy\"),\n            \"type\": \"list:string\",\n        },\n        \"bcc\": {\n            \"name\": _(\"Blind Carbon Copy\"),\n            \"type\": \"list:string\",\n        },\n    })\n\n    # Define our token control\n    template_kwargs = {\n        \"tokens\": {\n            \"name\": _(\"Template Tokens\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(self, client_id, client_secret, message_type=None,\n                 targets=None, cc=None, bcc=None, reply_to=None,\n                 channels=None, region=None, mode=None, from_addr=None,\n                 tokens=None, **kwargs):\n        \"\"\"\n        Initialize Notify NotificationAPI Object\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # Client ID\n        self.client_id = validate_regex(client_id)\n        if not self.client_id:\n            msg = \"An invalid NotificationAPI Client ID \" \\\n                  \"({}) was specified.\".format(client_id)\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Client Secret\n        self.client_secret = validate_regex(client_secret)\n        if not self.client_secret:\n            msg = \"An invalid NotificationAPI Client Secret \" \\\n                  \"({}) was specified.\".format(client_secret)\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        # Prepare our From Address\n        from_addr_ = [self.app_id, \"\"]\n        self.from_addr = None\n        if isinstance(from_addr, str):\n            result = is_email(from_addr)\n            if result:\n                from_addr_ = (\n                    result[\"name\"] if result[\"name\"] else from_addr_[0],\n                    result[\"full_email\"])\n            else:\n                # Only update the string but use the already detected info\n                from_addr_[0] = from_addr\n\n                # Store our lookup\n            self.from_addr = from_addr_[1]\n        self.names[from_addr_[1]] = from_addr_[0]\n\n        # Prepare our Reply-To Address\n        self.reply_to = {}\n        if isinstance(reply_to, str):\n            result = is_email(reply_to)\n            if result and \"full_email\" in result:\n                self.reply_to = {\n                    \"senderName\": result[\"name\"]\n                    if result[\"name\"] else from_addr_[0],\n                    \"senderEmail\": result[\"full_email\"],\n                }\n\n        # Our Targets are delimited by found ids\n        self.targets = []\n        if mode and isinstance(mode, str):\n            self.mode = next(\n                (a for a in NOTIFICATIONAPI_MODES if a.startswith(mode)), None\n            )\n            if self.mode not in NOTIFICATIONAPI_MODES:\n                msg = \\\n                    f\"The NotificationAPI mode specified ({mode}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            # Detect mode based on whether or not a message_type was provided\n            self.mode = NotificationAPIMode.MESSAGE if not message_type else \\\n                NotificationAPIMode.TEMPLATE\n\n        if not message_type:\n            # Assign a default message type\n            self.message_type = self.default_message_type\n\n        else:\n            self.message_type = validate_regex(\n                message_type, *self.template_tokens[\"type\"][\"regex\"])\n            if not self.message_type:\n                msg = \"An invalid NotificationAPI Message Type \" \\\n                      \"({}) was specified.\".format(message_type)\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        # Precompute auth header\n        # Ruby/docs show POST \"/{client_id}/sender\" with:\n        #      Basic base64(client_id:client_secret)\n        # https://www.notificationapi.com/docs/reference/server\n        token = base64.b64encode(\n            f\"{self.client_id}:{self.client_secret}\".encode()).decode(\"ascii\")\n        self.auth_header = f\"Basic {token}\"\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # Store our region\n        try:\n            self.region = (\n                self.template_args[\"region\"][\"default\"]\n                if region is None else region.lower()\n            )\n\n            if self.region not in NOTIFICATIONAPI_REGIONS:\n                # allow the outer except to handle this common response\n                raise IndexError()\n\n        except (AttributeError, IndexError, TypeError):\n            # Invalid region specified\n            msg = \\\n                f\"The NotificationAPI region specified ({region}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        # Initialize an empty set of channels\n        self.channels = set()\n        for channel_ in parse_list(channels):\n            channel = channel_.lower()\n            if channel not in NOTIFICATIONAPI_CHANNELS:\n                # Invalid channel specified\n                msg = (\n                    \"The NotificationAPI forced channel specified \"\n                    f\"({channel}) is invalid.\")\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n            self.channels.add(channel)\n\n        # Used for URL generation afterwards only\n        self._invalid_targets = []\n\n        if targets:\n            current_target = {}\n            for entry in parse_list(targets, sort=False):\n                result = is_email(entry)\n                if result:\n                    if \"email\" not in current_target:\n                        current_target[\"email\"] = result[\"full_email\"]\n                        if not self.channels:\n                            self.channels.add(NotificationAPIChannel.EMAIL)\n                            self.logger.info(\n                                \"The NotificationAPI default channel of \"\n                                f\"{NotificationAPIChannel.EMAIL} was set.\")\n                        continue\n\n                    elif \"id\" in current_target:\n                        # Store and move on\n                        self.targets.append(current_target)\n                        current_target = {\n                            \"email\": result[\"full_email\"]\n                        }\n                        continue\n\n                    # if we got here, we have to many emails making it now\n                    # ambiguous as to who the sender intended to notify\n                    msg = (\n                        \"The NotificationAPI received too many emails \"\n                        \"creating an ambiguous situation; aborted at \"\n                        f\"'{entry}'.\")\n                    self.logger.warning(msg)\n                    raise TypeError(msg) from None\n\n                result = is_phone_no(entry)\n                if result:\n                    if \"number\" not in current_target:\n                        current_target[\"number\"] = \\\n                            (\"+\" if entry[0] == \"+\" else \"\") + result[\"full\"]\n                        if not self.channels:\n                            self.channels.add(NotificationAPIChannel.SMS)\n                            self.logger.info(\n                                \"The NotificationAPI default channel of \"\n                                f\"{NotificationAPIChannel.SMS} was set.\")\n                        continue\n\n                    elif \"id\" in current_target:\n                        # Store and move on\n                        self.targets.append(current_target)\n                        current_target = {\n                            \"number\": result[\"full\"]\n                        }\n                        continue\n\n                    # if we got here, we have to many emails making it now\n                    # ambiguous as to who the sender intended to notify\n                    msg = (\n                        \"The NotificationAPI received too many phone no's \"\n                        \"creating an ambiguous situation; aborted at \"\n                        f\"'{entry}'.\")\n                    self.logger.warning(msg)\n                    raise TypeError(msg) from None\n\n                result = IS_VALID_ID_RE.match(entry)\n                if result:\n                    if \"id\" not in current_target:\n                        current_target[\"id\"] = result.group(\"id\")\n                        continue\n\n                    # Store id in next target and move on\n                    self.targets.append(current_target)\n                    current_target = {\n                        \"id\": result.group(\"id\")\n                    }\n                    continue\n\n                self.logger.warning(\n                    \"Dropped invalid NotificationAPI target \"\n                    f\"({entry}) specified\")\n                self._invalid_targets.append(entry)\n                continue\n\n            if \"id\" in current_target:\n                # Store our final entry\n                self.targets.append(current_target)\n                current_target = {}\n\n            if current_target:\n                # we have email or sms, but no id to go with it\n                msg = (\n                    \"The NotificationAPI did not detect an id to \"\n                    \"correlate the following with {}\".format(\n                        str(current_target)))\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n            result = is_email(recipient)\n            if result:\n                self.cc.add(result[\"full_email\"])\n                if result[\"name\"]:\n                    self.names[result[\"full_email\"]] = result[\"name\"]\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Carbon Copy email \"\n                \"({}) specified.\".format(recipient),\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n\n            result = is_email(recipient)\n            if result:\n                self.bcc.add(result[\"full_email\"])\n                if result[\"name\"]:\n                    self.names[result[\"full_email\"]] = result[\"name\"]\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                \"({}) specified.\".format(recipient),\n            )\n\n        # Template functionality\n        self.tokens = {}\n        if isinstance(tokens, dict):\n            self.tokens.update(tokens)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"\n        Returns all of the identifiers that make this URL unique from\n        another similar one. Targets or end points should never be identified\n        here.\n        \"\"\"\n        return (self.secure_protocol[0], self.client_id, self.client_secret)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"\n        Returns the URL built dynamically based on specified arguments.\n        \"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"mode\": self.mode,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if len(self.cc) > 0:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for it's escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\").replace(\",\", \"%2C\")\n                for e in self.cc])\n\n        if len(self.bcc) > 0:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for it's escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\").replace(\",\", \"%2C\")\n                for e in self.bcc])\n\n        if self.reply_to:\n            # Handle our Reply-To Address\n            params[\"reply\"] = formataddr(\n                (self.reply_to[\"senderName\"], self.reply_to[\"senderEmail\"]),\n                # Swap comma for its escaped url code (if detected) since\n                # we're using that as a delimiter\n                charset=\"utf-8\",\n            )\n\n        if self.channels:\n            # Prepare our default channel\n            params[\"channels\"] = \",\".join(self.channels)\n\n        if self.region != self.template_args[\"region\"][\"default\"]:\n            # Prepare our default region\n            params[\"region\"] = self.region\n\n        # handle from=\n        if self.from_addr and self.names[self.from_addr] != self.app_id:\n            params[\"from\"] = self.names[self.from_addr]\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n        # Store any template entries if specified\n        params.update({f\":{k}\": v for k, v in self.tokens.items()})\n\n        targets = []\n        for target in self.targets:\n            # ID is always present\n            targets.append(f\"@{target['id']}\")\n            if \"number\" in target:\n                targets.append(f\"{target['number']}\")\n            if \"email\" in target:\n                targets.append(f\"{target['email']}\")\n\n        mtype = f\"{self.message_type}@\" \\\n            if self.message_type != self.default_message_type else \"\"\n        return \"{schema}://{mtype}{cid}/{secret}/{targets}?{params}\".format(\n            schema=self.secure_protocol[0],\n            mtype=mtype,\n            cid=self.pprint(self.client_id, privacy, safe=\"\"),\n            secret=self.pprint(self.client_secret, privacy, safe=\"\"),\n            targets=NotifyNotificationAPI.quote(\"/\".join(\n                chain(targets, self._invalid_targets)), safe=\"/\"),\n            params=NotifyNotificationAPI.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"\n        Returns the number of targets associated with this notification\n        \"\"\"\n\n        return max(1, len(self.targets))\n\n    def gen_payload(self, body, title=\"\", notify_type=NotifyType.INFO,\n                    **kwargs):\n        \"\"\"\n        generates our NotificationAPI payload\n        \"\"\"\n\n        payload_ = {\n            \"type\": self.message_type,\n        }\n        if self.mode == NotificationAPIMode.TEMPLATE:\n            # Take a copy of our token dictionary\n            parameters = self.tokens.copy()\n\n            # Apply some defaults template values\n            parameters[\"appBody\"] = body\n            parameters[\"appTitle\"] = title\n            parameters[\"appType\"] = notify_type.value\n            parameters[\"appId\"] = self.app_id\n            parameters[\"appDescription\"] = self.app_desc\n            parameters[\"appColor\"] = self.color(notify_type)\n            parameters[\"appImageUrl\"] = self.image_url(notify_type)\n            parameters[\"appUrl\"] = self.app_url\n\n            # A Simple Email Payload Template\n            payload_.update({\n                \"parameters\": {**parameters},\n            })\n\n        else:\n            # Acquire text version of body if provided\n            text_body = convert_between(\n                NotifyFormat.HTML, NotifyFormat.TEXT, body) \\\n                if self.notify_format == NotifyFormat.HTML else body\n\n            for channel in self.channels:\n                # Python v3.10 supports `match/case` but since Apprise aims to\n                # be compatible with Python v3.9+, we must use if/else for the\n                # time being\n                if channel == NotificationAPIChannel.SMS:\n                    payload_.update({\n                        NotificationAPIChannel.SMS: {\n                            \"message\": (title + \"\\n\" + text_body)\n                            if title else text_body,\n                        },\n                    })\n\n                elif channel == NotificationAPIChannel.EMAIL:\n                    html_body = convert_between(\n                        NotifyFormat.TEXT, NotifyFormat.HTML, body) \\\n                        if self.notify_format != NotifyFormat.HTML else body\n\n                    payload_.update({\n                        NotificationAPIChannel.EMAIL: {\n                            \"subject\": title if title else self.app_id,\n                            \"html\": html_body,\n                        },\n                    })\n\n                    if self.from_addr:\n                        payload_[NotificationAPIChannel.EMAIL].update({\n                            \"senderEmail\": self.from_addr,\n                            \"senderName\": self.names[self.from_addr],\n                        })\n\n                elif channel == NotificationAPIChannel.INAPP:\n                    payload_.update({\n                        NotificationAPIChannel.INAPP: {\n                            \"title\": title if title else self.app_id,\n                            \"image\": self.image_url(notify_type),\n                        },\n                    })\n\n                elif channel == NotificationAPIChannel.WEB_PUSH:\n                    payload_.update({\n                        NotificationAPIChannel.WEB_PUSH: {\n                            \"title\": title if title else self.app_id,\n                            \"message\": text_body,\n                            \"icon\": self.image_url(notify_type),\n                        },\n                    })\n\n                elif channel == NotificationAPIChannel.MOBILE_PUSH:\n                    payload_.update({\n                        NotificationAPIChannel.MOBILE_PUSH: {\n                            \"title\": title if title else self.app_id,\n                            \"message\": text_body,\n                        },\n                    })\n\n                else:  # channel == NotificationAPIChannel.SLACK\n                    payload_.update({\n                        NotificationAPIChannel.SLACK: {\n                            \"text\": (title + \"\\n\" + text_body)\n                            if title else text_body,\n                        },\n                    })\n\n        # Copy our list to work with\n        targets = list(self.targets)\n        if self.from_addr:\n            payload_.update({\n                \"options\": {\n                    \"email\": {\n                        \"fromAddress\": self.from_addr,\n                        \"fromName\": self.names[self.from_addr]}}})\n\n        elif self.cc or self.bcc:\n            # Set up shell\n            payload_.update({\"options\": {\"email\": {}}})\n\n        while len(targets) > 0:\n            target = targets.pop(0)\n\n            # Create a copy of our template\n            payload = payload_.copy()\n\n            # the cc, bcc, to field must be unique or SendMail will fail,\n            # the below code prepares this by ensuring the target isn't in\n            # the cc list or bcc list. It also makes sure the cc list does\n            # not contain any of the bcc entries\n            if \"email\" in target:\n                cc = (self.cc - self.bcc - {target[\"email\"]})\n                bcc = (self.bcc - {target[\"email\"]})\n\n            else:\n                # Assume defaults\n                cc = self.cc\n                bcc = self.bcc\n\n            #\n            # Prepare our 'to'\n            #\n            payload[\"to\"] = {**target}\n\n            # Support cc/bcc\n            if len(cc):\n                payload[\"options\"][\"email\"][\"ccAddresses\"] = list(cc)\n            if len(bcc):\n                payload[\"options\"][\"email\"][\"bccAddresses\"] = list(bcc)\n\n            yield payload\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"\n        Perform NotificationAPI Notification\n        \"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        if not self.targets:\n            # There is no one to email or send an sms message to; we're done\n            self.logger.warning(\n                \"There are no NotificationAPI recipients to notify\"\n            )\n            return False\n\n        # Prepare our URL\n        url = (\n            f\"{NOTIFICATIONAPI_API_LOOKUP[self.region]}/\"\n            f\"{self.client_id}/sender\")\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": self.auth_header,\n        }\n\n        for payload in self.gen_payload(\n                body, title=title, notify_type=notify_type, **kwargs):\n            # Perform our post\n            self.logger.debug(\n                \"NotificationAPI POST URL: {} (cert_verify={!r})\".format(\n                    url, self.verify_certificate))\n            self.logger.debug(\n                \"NotificationAPI Payload: %s\", payload[\"to\"][\"id\"])\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                try:\n                    loads(r.content)\n\n                except (AttributeError, TypeError, ValueError):\n                    # This gets thrown if we can't parse our JSON Response\n                    #  - ValueError = r.content is Unparsable\n                    #  - TypeError = r.content is None\n                    #  - AttributeError = r is None\n                    self.logger.warning(\n                        \"Invalid response from NotificationAPI server.\")\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Record our failure\n                    has_error = True\n                    continue\n\n                # Reference status code\n                status_code = r.status_code\n\n                if status_code not in (\n                        requests.codes.ok, requests.codes.accepted):\n                    # We had a problem\n                    status_str = \\\n                        NotifyNotificationAPI.http_response_code_lookup(\n                            status_code)\n\n                    self.logger.warning(\n                        \"Failed to send NotificationAPI notification to %s: \"\n                        \"%s%serror=%d\",\n                        payload[\"to\"][\"id\"],\n                        status_str,\n                        \", \" if status_str else \"\",\n                        status_code)\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Record our failure\n                    has_error = True\n\n                else:\n                    self.logger.info(\n                        \"Sent NotificationAPI notification to %s.\",\n                        payload[\"to\"][\"id\"])\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending NotificationAPI \"\n                    \"notification to %s.\", payload[\"to\"][\"id\"])\n                self.logger.debug(\"Socket Exception: {}\".format(str(e)))\n\n                # Record our failure\n                has_error = True\n\n        return not has_error\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"\n        Parses the URL and returns enough arguments that can allow\n        us to re-instantiate this object.\n\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Define our minimum requirements; defining them now saves us from\n        # having to if/else all kinds of branches below...\n        results[\"client_id\"] = None\n        results[\"client_secret\"] = None\n\n        # Prepare our targets (starting with our host)\n        results[\"targets\"] = []\n        if results[\"host\"]:\n            results[\"targets\"].append(\n                NotifyNotificationAPI.unquote(results[\"host\"]))\n\n        # For tracking email sources\n        results[\"from_addr\"] = None\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"from_addr\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"from\"].rstrip())\n\n        # First 2 elements are the client_id and client_secret\n        # Following are targets\n        results[\"targets\"] += \\\n            NotifyNotificationAPI.split_path(results[\"fullpath\"])\n        # check for our client id\n        if \"id\" in results[\"qsd\"] and len(results[\"qsd\"][\"id\"]):\n            # Store our Client ID\n            results[\"client_id\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"id\"])\n\n        elif results[\"targets\"]:\n            # Store our Client ID\n            results[\"client_id\"] = results[\"targets\"].pop(0)\n\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            # Store our Client Secret\n            results[\"client_secret\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"secret\"])\n\n        elif results[\"targets\"]:\n            # Store our Client Secret\n            results[\"client_secret\"] = results[\"targets\"].pop(0)\n\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            results[\"region\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"region\"])\n\n        if \"channels\" in results[\"qsd\"] and len(results[\"qsd\"][\"channels\"]):\n            results[\"channels\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"channels\"])\n\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            results[\"mode\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"mode\"])\n\n        if \"reply\" in results[\"qsd\"] and len(results[\"qsd\"][\"reply\"]):\n            results[\"reply_to\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"reply\"])\n\n        # Handling of Message Type\n        if \"type\" in results[\"qsd\"] and len(results[\"qsd\"][\"type\"]):\n            results[\"message_type\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"type\"])\n\n        elif results[\"user\"]:\n            # Pull from user\n            results[\"message_type\"] = \\\n                NotifyNotificationAPI.unquote(results[\"user\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"to\"]))\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"cc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = \\\n                NotifyNotificationAPI.unquote(results[\"qsd\"][\"bcc\"])\n\n        # Store our tokens\n        results[\"tokens\"] = results[\"qsd:\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/notifico.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Notifico allows you to relay notifications into IRC channels.\n#\n# 1. visit https://n.tkte.ch and sign up for an account\n# 2. create a project; either manually or sync with github\n# 3. from within the project, you can create a message hook\n#\n# the URL will look something like this:\n#       https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj\n#                            ^                ^\n#                            |                |\n#                         project id       message hook\n#\n# This plugin also supports taking the URL (as identified above) directly\n# as well.\n\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotificoFormat:\n    # Resets all formatting\n    Reset = \"\\x0f\"\n\n    # Formatting\n    Bold = \"\\x02\"\n    Italic = \"\\x1d\"\n    Underline = \"\\x1f\"\n    BGSwap = \"\\x16\"\n\n\nclass NotificoColor:\n    # Resets Color\n    Reset = \"\\x03\"\n\n    # Colors\n    White = \"\\x0300\"\n    Black = \"\\x0301\"\n    Blue = \"\\x0302\"\n    Green = \"\\x0303\"\n    Red = \"\\x0304\"\n    Brown = \"\\x0305\"\n    Purple = \"\\x0306\"\n    Orange = \"\\x0307\"\n    Yellow = (\"\\x0308\",)\n    LightGreen = \"\\x0309\"\n    Teal = \"\\x0310\"\n    LightCyan = \"\\x0311\"\n    LightBlue = \"\\x0312\"\n    Violet = \"\\x0313\"\n    Grey = \"\\x0314\"\n    LightGrey = \"\\x0315\"\n\n\nclass NotifyNotifico(NotifyBase):\n    \"\"\"A wrapper for Notifico Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Notifico\"\n\n    # The services URL\n    service_url = \"https://n.tkte.ch\"\n\n    # The default protocol\n    protocol = \"notifico\"\n\n    # The default secure protocol\n    secure_protocol = \"notifico\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/notifico/\"\n\n    # Plain Text Notification URL\n    notify_url = \"https://n.tkte.ch/h/{proj}/{hook}\"\n\n    # The title is not used\n    title_maxlen = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 512\n\n    # Define object templates\n    templates = (\"{schema}://{project_id}/{msghook}\",)\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            # The Project ID is found as the first part of the URL\n            #  /1234/........................\n            \"project_id\": {\n                \"name\": _(\"Project ID\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[0-9]+$\", \"\"),\n            },\n            # The Message Hook follows the Project ID\n            #  /..../AbCdEfGhIjKlMnOpQrStUvWX\n            \"msghook\": {\n                \"name\": _(\"Message Hook\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            # You can optionally pass IRC colors into\n            \"color\": {\n                \"name\": _(\"IRC Colors\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            # You can optionally pass IRC color into\n            \"prefix\": {\n                \"name\": _(\"Prefix\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n        },\n    )\n\n    def __init__(self, project_id, msghook, color=True, prefix=True, **kwargs):\n        \"\"\"Initialize Notifico Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Assign our message hook\n        self.project_id = validate_regex(\n            project_id, *self.template_tokens[\"project_id\"][\"regex\"]\n        )\n        if not self.project_id:\n            msg = (\n                f\"An invalid Notifico Project ID ({project_id}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Assign our message hook\n        self.msghook = validate_regex(\n            msghook, *self.template_tokens[\"msghook\"][\"regex\"]\n        )\n        if not self.msghook:\n            msg = (\n                f\"An invalid Notifico Message Token ({msghook}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prefix messages with a [?] where ? identifies the message type\n        # such as if it's an error, warning, info, or success\n        self.prefix = prefix\n\n        # Send colors\n        self.color = color\n\n        # Prepare our notification URL now:\n        self.api_url = self.notify_url.format(\n            proj=self.project_id,\n            hook=self.msghook,\n        )\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.project_id, self.msghook)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"color\": \"yes\" if self.color else \"no\",\n            \"prefix\": \"yes\" if self.prefix else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{proj}/{hook}/?{params}\".format(\n            schema=self.secure_protocol,\n            proj=self.pprint(self.project_id, privacy, safe=\"\"),\n            hook=self.pprint(self.msghook, privacy, safe=\"\"),\n            params=NotifyNotifico.urlencode(params),\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Wrapper to _send since we can alert more then one channel.\"\"\"\n\n        # prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n        }\n\n        # Prepare our IRC Prefix\n        color = \"\"\n        token = \"\"\n        if notify_type == NotifyType.INFO:\n            color = NotificoColor.Teal\n            token = \"i\"\n\n        elif notify_type == NotifyType.SUCCESS:\n            color = NotificoColor.LightGreen\n            token = \"✔\"\n\n        elif notify_type == NotifyType.WARNING:\n            color = NotificoColor.Orange\n            token = \"!\"\n\n        elif notify_type == NotifyType.FAILURE:\n            color = NotificoColor.Red\n            token = \"✗\"\n\n        if self.color:\n            # Colors were specified, make sure we capture and correctly\n            # allow them to exist inline in the message\n            # \\g<1> is less ambiguous than \\1\n            body = re.sub(r\"\\\\x03(\\d{0,2})\", r\"\\\\x03\\g<1>\", body)\n\n        else:\n            # no colors specified, make sure we strip out any colors found\n            # to make the string read-able\n            body = re.sub(r\"\\\\x03(\\d{1,2}(,[0-9]{1,2})?)?\", r\"\", body)\n\n        # Prepare our payload\n        payload = {\n            \"payload\": (\n                body\n                if not self.prefix\n                else \"{}[{}]{} {}{}{}: {}{}\".format(\n                    # Token [?] at the head\n                    color if self.color else \"\",\n                    token,\n                    NotificoColor.Reset if self.color else \"\",\n                    # App ID\n                    NotificoFormat.Bold if self.color else \"\",\n                    self.app_id,\n                    NotificoFormat.Reset if self.color else \"\",\n                    # Message Body\n                    body,\n                    # Reset\n                    NotificoFormat.Reset if self.color else \"\",\n                )\n            ),\n        }\n\n        self.logger.debug(\n            \"Notifico GET URL:\"\n            f\" {self.api_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Notifico Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.get(\n                self.api_url,\n                params=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyNotifico.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Notifico notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Notifico notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Notifico notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The first token is stored in the hostname\n        results[\"project_id\"] = NotifyNotifico.unquote(results[\"host\"])\n\n        # Get Message Hook\n        try:\n            results[\"msghook\"] = NotifyNotifico.split_path(\n                results[\"fullpath\"]\n            )[0]\n\n        except IndexError:\n            results[\"msghook\"] = None\n\n        # Include Color\n        results[\"color\"] = parse_bool(results[\"qsd\"].get(\"color\", True))\n\n        # Include Prefix\n        results[\"prefix\"] = parse_bool(results[\"qsd\"].get(\"prefix\", True))\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://n.tkte.ch/h/PROJ_ID/MESSAGE_HOOK/\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://n\\.tkte\\.ch/h/\"\n            r\"(?P<proj>[0-9]+)/\"\n            r\"(?P<hook>[A-Z0-9]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyNotifico.parse_url(\n                \"{schema}://{proj}/{hook}/{params}\".format(\n                    schema=NotifyNotifico.secure_protocol,\n                    proj=result.group(\"proj\"),\n                    hook=result.group(\"hook\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/ntfy.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Great sources\n# - https://github.com/matrix-org/matrix-python-sdk\n# - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst\n#\n# Examples:\n#   ntfys://my-topic\n#   ntfy://ntfy.local.domain/my-topic\n#   ntfys://ntfy.local.domain:8080/my-topic\n#   ntfy://ntfy.local.domain/?priority=max\nfrom json import dumps, loads\nfrom os.path import basename\nimport re\nfrom urllib.parse import quote\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..attachment.memory import AttachMemory\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import (\n    is_hostname,\n    is_ipaddr,\n    parse_bool,\n    parse_list,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n\nclass NtfyMode:\n    \"\"\"Define ntfy Notification Modes.\"\"\"\n\n    # App posts upstream to the developer API on ntfy's website\n    CLOUD = \"cloud\"\n\n    # Running a dedicated private ntfy Server\n    PRIVATE = \"private\"\n\n\nNTFY_MODES = (\n    NtfyMode.CLOUD,\n    NtfyMode.PRIVATE,\n)\n\n# A Simple regular expression used to auto detect Auth mode if it isn't\n# otherwise specified:\nNTFY_AUTH_DETECT_RE = re.compile(r\"tk_[^ \\t]+\", re.IGNORECASE)\n\n\nclass NtfyAuth:\n    \"\"\"Define ntfy Authentication Modes.\"\"\"\n\n    # Basic auth (user and password provided)\n    BASIC = \"basic\"\n\n    # Auth Token based\n    TOKEN = \"token\"\n\n\nNTFY_AUTH = (\n    NtfyAuth.BASIC,\n    NtfyAuth.TOKEN,\n)\n\n\nclass NtfyPriority:\n    \"\"\"Ntfy Priority Definitions.\"\"\"\n\n    MAX = \"max\"\n    HIGH = \"high\"\n    NORMAL = \"default\"\n    LOW = \"low\"\n    MIN = \"min\"\n\n\nNTFY_PRIORITIES = (\n    NtfyPriority.MAX,\n    NtfyPriority.HIGH,\n    NtfyPriority.NORMAL,\n    NtfyPriority.LOW,\n    NtfyPriority.MIN,\n)\n\nNTFY_PRIORITY_MAP = {\n    # Maps against string 'low' but maps to Moderate to avoid\n    # conflicting with actual ntfy mappings\n    \"l\": NtfyPriority.LOW,\n    # Maps against string 'moderate'\n    \"mo\": NtfyPriority.LOW,\n    # Maps against string 'normal'\n    \"n\": NtfyPriority.NORMAL,\n    # Maps against string 'high'\n    \"h\": NtfyPriority.HIGH,\n    # Maps against string 'emergency'\n    \"e\": NtfyPriority.MAX,\n    # Entries to additionally support (so more like Ntfy's API)\n    # Maps against string 'min'\n    \"mi\": NtfyPriority.MIN,\n    # Maps against string 'max'\n    \"ma\": NtfyPriority.MAX,\n    # Maps against string 'default'\n    \"d\": NtfyPriority.NORMAL,\n    # support 1-5 values as well\n    \"1\": NtfyPriority.MIN,\n    # Maps against string 'moderate'\n    \"2\": NtfyPriority.LOW,\n    # Maps against string 'normal'\n    \"3\": NtfyPriority.NORMAL,\n    # Maps against string 'high'\n    \"4\": NtfyPriority.HIGH,\n    # Maps against string 'emergency'\n    \"5\": NtfyPriority.MAX,\n}\n\n\nclass NotifyNtfy(NotifyBase):\n    \"\"\"A wrapper for ntfy Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"ntfy\"\n\n    # The services URL\n    service_url = \"https://ntfy.sh/\"\n\n    # Insecure protocol (for those self hosted requests)\n    protocol = \"ntfy\"\n\n    # The default protocol\n    secure_protocol = \"ntfys\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/ntfy/\"\n\n    # Default upstream/cloud host if none is defined\n    cloud_notify_url = \"https://ntfy.sh\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Maximum title length\n    title_maxlen = 200\n\n    # Maximum body length\n    body_maxlen = 7800\n\n    # Message size calculates title and body together\n    overflow_amalgamate_title = True\n\n    # Defines the number of bytes our JSON object can not exceed in size or we\n    # know the upstream server will reject it.  We convert these into\n    # attachments\n    ntfy_json_upstream_size_limit = 8000\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_256\n\n    # Message time to live (if remote client isn't around to receive it)\n    time_to_live = 2419200\n\n    # if our hostname matches the following we automatically enforce\n    # cloud mode\n    __auto_cloud_host = re.compile(r\"ntfy\\.sh\", re.IGNORECASE)\n\n    # Define object templates\n    templates = (\n        \"{schema}://{topic}\",\n        \"{schema}://{host}/{targets}\",\n        \"{schema}://{host}:{port}/{targets}\",\n        \"{schema}://{user}@{host}/{targets}\",\n        \"{schema}://{user}@{host}:{port}/{targets}\",\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n        \"{schema}://{token}@{host}/{targets}\",\n        \"{schema}://{token}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"topic\": {\n                \"name\": _(\"Topic\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n                \"regex\": (r\"^[a-z0-9_-]{1,64}$\", \"i\"),\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"attach\": {\n                \"name\": _(\"Attach\"),\n                \"type\": \"string\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"avatar_url\": {\n                \"name\": _(\"Avatar URL\"),\n                \"type\": \"string\",\n            },\n            \"filename\": {\n                \"name\": _(\"Attach Filename\"),\n                \"type\": \"string\",\n            },\n            \"click\": {\n                \"name\": _(\"Click\"),\n                \"type\": \"string\",\n            },\n            \"delay\": {\n                \"name\": _(\"Delay\"),\n                \"type\": \"string\",\n            },\n            \"email\": {\n                \"name\": _(\"Email\"),\n                \"type\": \"string\",\n            },\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:string\",\n                \"values\": NTFY_PRIORITIES,\n                \"default\": NtfyPriority.NORMAL,\n            },\n            \"tags\": {\n                \"name\": _(\"Tags\"),\n                \"type\": \"string\",\n            },\n            \"actions\": {\n                \"name\": _(\"Actions\"),\n                \"type\": \"string\",\n            },\n            \"mode\": {\n                \"name\": _(\"Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": NTFY_MODES,\n                \"default\": NtfyMode.PRIVATE,\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"auth\": {\n                \"name\": _(\"Authentication Type\"),\n                \"type\": \"choice:string\",\n                \"values\": NTFY_AUTH,\n                \"default\": NtfyAuth.BASIC,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        targets=None,\n        attach=None,\n        filename=None,\n        click=None,\n        delay=None,\n        email=None,\n        priority=None,\n        tags=None,\n        actions=None,\n        mode=None,\n        include_image=True,\n        avatar_url=None,\n        auth=None,\n        token=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize ntfy Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare our mode\n        self.mode = (\n            mode.strip().lower()\n            if isinstance(mode, str)\n            else self.template_args[\"mode\"][\"default\"]\n        )\n\n        if self.mode not in NTFY_MODES:\n            msg = f\"An invalid ntfy Mode ({mode}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Show image associated with notification\n        self.include_image = include_image\n\n        # Prepare our authentication type\n        self.auth = (\n            auth.strip().lower()\n            if isinstance(auth, str)\n            else self.template_args[\"auth\"][\"default\"]\n        )\n\n        if self.auth not in NTFY_AUTH:\n            msg = (\n                f\"An invalid ntfy Authentication type ({auth}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Attach a file (URL supported)\n        self.attach = attach\n\n        # Our filename (if defined)\n        self.filename = filename\n\n        # A clickthrough option for notifications\n        # Support Internationalized URLs\n        self.click = (\n            None\n            if not isinstance(click, str)\n            else (\n                click\n                if not any(ord(char) > 127 for char in click)\n                else quote(click, safe=\":/?&=[]\")\n            )\n        )\n\n        # Time delay for notifications (various string formats)\n        self.delay = delay\n\n        # An email to forward notifications to\n        self.email = email\n\n        # Save our token\n        self.token = token\n\n        # The Priority of the message\n        self.priority = (\n            NotifyNtfy.template_args[\"priority\"][\"default\"]\n            if not priority\n            else next(\n                (\n                    v\n                    for k, v in NTFY_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyNtfy.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        # Any optional tags to attach to the notification\n        self.__tags = parse_list(tags)\n\n        # Action buttons\n        self.__actions = actions\n\n        # Avatar URL\n        # This allows a user to provide an over-ride to the otherwise\n        # dynamically generated avatar url images\n        self.avatar_url = avatar_url\n\n        # Build list of topics\n        topics = parse_list(targets)\n        self.topics = []\n        for topic_ in topics:\n            topic = validate_regex(\n                topic_, *self.template_tokens[\"topic\"][\"regex\"]\n            )\n            if not topic:\n                self.logger.warning(\n                    f\"A specified ntfy topic ({topic_}) is invalid and will be\"\n                    \" ignored\"\n                )\n                continue\n            self.topics.append(topic)\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform ntfy Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        if not len(self.topics):\n            # We have nothing to notify; we're done\n            self.logger.warning(\"There are no ntfy topics to notify\")\n            return False\n\n        # Acquire image_url\n        image_url = self.image_url(notify_type)\n\n        if self.include_image and (image_url or self.avatar_url):\n            image_url = self.avatar_url if self.avatar_url else image_url\n        else:\n            image_url = None\n\n        # Create a copy of the topics\n        topics = list(self.topics)\n        while len(topics) > 0:\n            # Retrieve our topic\n            topic = topics.pop()\n\n            if attach and self.attachment_support:\n                # We need to upload our payload first so that we can source it\n                # in remaining messages\n                for no, attachment in enumerate(attach):\n\n                    # First message only includes the text (if defined)\n                    body_ = body if not no and body else None\n                    title_ = title if not no and title else None\n\n                    # Perform some simple error checking\n                    if not attachment:\n                        # We could not access the attachment\n                        self.logger.error(\n                            \"Could not access attachment\"\n                            f\" {attachment.url(privacy=True)}.\"\n                        )\n                        return False\n\n                    self.logger.debug(\n                        \"Preparing ntfy attachment\"\n                        f\" {attachment.url(privacy=True)}\"\n                    )\n\n                    okay, _response = self._send(\n                        topic,\n                        body=body_,\n                        title=title_,\n                        image_url=image_url,\n                        attach=attachment,\n                    )\n                    if not okay:\n                        # We can't post our attachment; abort immediately\n                        return False\n            else:\n                # Send our Notification Message\n                okay, _response = self._send(\n                    topic, body=body, title=title, image_url=image_url\n                )\n                if not okay:\n                    # Mark our failure, but contiue to move on\n                    has_error = True\n\n        return not has_error\n\n    def _send(\n        self,\n        topic,\n        body=None,\n        title=None,\n        attach=None,\n        image_url=None,\n        **kwargs,\n    ):\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # See https://ntfy.sh/docs/publish/#publish-as-json\n        data = {}\n\n        # Posting Parameters\n        params = {}\n\n        auth = None\n        if self.mode == NtfyMode.CLOUD:\n            # Cloud Service\n            notify_url = self.cloud_notify_url\n\n        else:  # NotifyNtfy.PRVATE\n            # Allow more settings to be applied now\n            if self.auth == NtfyAuth.BASIC and self.user:\n                auth = (self.user, self.password)\n\n            elif self.auth == NtfyAuth.TOKEN:\n                if not self.token:\n                    self.logger.warning(\"No Ntfy Token was specified\")\n                    return False, None\n\n                # Set Token\n                headers[\"Authorization\"] = f\"Bearer {self.token}\"\n\n            # Prepare our ntfy Template URL\n            schema = \"https\" if self.secure else \"http\"\n\n            notify_url = f\"{schema}://{self.host}\"\n            if isinstance(self.port, int):\n                notify_url += f\":{self.port}\"\n\n        if not attach:\n            headers[\"Content-Type\"] = \"application/json\"\n\n            data[\"topic\"] = topic\n            virt_payload = data\n\n            if self.attach:\n                virt_payload[\"attach\"] = self.attach\n\n                if self.filename:\n                    virt_payload[\"filename\"] = self.filename\n\n        else:\n            # Point our payload to our parameters\n            virt_payload = params\n            notify_url += f\"/{topic}\"\n\n            # Prepare our Header\n            virt_payload[\"filename\"] = attach.name\n\n            with attach as fp:\n                data = fp.read()\n\n        if image_url:\n            headers[\"X-Icon\"] = image_url\n\n        if title:\n            virt_payload[\"title\"] = title\n\n        if body:\n            virt_payload[\"message\"] = body\n\n        if self.notify_format == NotifyFormat.MARKDOWN:\n            # Support Markdown\n            headers[\"X-Markdown\"] = \"yes\"\n\n        if self.priority != NtfyPriority.NORMAL:\n            headers[\"X-Priority\"] = self.priority\n\n        if self.delay is not None:\n            headers[\"X-Delay\"] = self.delay\n\n        if self.click is not None:\n            headers[\"X-Click\"] = quote(self.click, safe=\":/?@&=#\")\n\n        if self.email is not None:\n            headers[\"X-Email\"] = self.email\n\n        if self.__tags:\n            headers[\"X-Tags\"] = \",\".join(self.__tags)\n\n        if self.__actions:\n            headers[\"X-Actions\"] = self.__actions\n\n        self.logger.debug(\n            \"ntfy POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n\n        # Default response type\n        response = None\n\n        if not attach:\n            data = dumps(data)\n            if len(data) > self.ntfy_json_upstream_size_limit:\n                # Convert to an attachment\n\n                if self.notify_format == NotifyFormat.MARKDOWN:\n                    mimetype = \"text/markdown\"\n\n                elif self.notify_format == NotifyFormat.TEXT:\n                    mimetype = \"text/plain\"\n\n                else:  # self.notify_format == NotifyFormat.HTML:\n                    mimetype = \"text/html\"\n\n                attach = AttachMemory(\n                    mimetype=mimetype,\n                    content=\"{title}{body}\".format(\n                        title=title + \"\\n\" if title else \"\", body=body\n                    ),\n                )\n\n                # Recursively send the message body as an attachment instead\n                return self._send(\n                    topic=topic,\n                    body=\"\",\n                    title=\"\",\n                    attach=attach,\n                    image_url=image_url,\n                    **kwargs,\n                )\n\n        self.logger.debug(f\"ntfy Payload: {virt_payload!s}\")\n        self.logger.debug(f\"ntfy Headers: {headers!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                notify_url,\n                params=params if params else None,\n                data=data,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code\n                )\n\n                # set up our status code to use\n                status_code = r.status_code\n\n                try:\n                    # Update our status response if we can\n                    response = loads(r.content)\n                    status_str = response.get(\"error\", status_str)\n                    status_code = int(response.get(\"code\", status_code))\n\n                except (AttributeError, TypeError, ValueError):\n                    # ValueError = r.content is Unparsable\n                    # TypeError = r.content is None\n                    # AttributeError = r is None\n\n                    # We could not parse JSON response.\n                    # We will just use the status we already have.\n                    pass\n\n                self.logger.warning(\n                    \"Failed to send ntfy notification to topic '{}': \"\n                    \"{}{}error={}.\".format(\n                        topic,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False, response\n\n            # otherwise we were successful\n            self.logger.info(f\"Sent ntfy notification to '{notify_url}'.\")\n\n            return True, response\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"A Connection error occurred sending ntfy:{notify_url} \"\n                + \"notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while handling {}.\".format(\n                    attach.name\n                    if isinstance(attach, AttachBase)\n                    else virt_payload\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n\n        return False, response\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n\n        kwargs = [\n            (\n                self.secure_protocol\n                if self.mode == NtfyMode.CLOUD\n                else (self.secure_protocol if self.secure else self.protocol)\n            ),\n            self.host if self.mode == NtfyMode.PRIVATE else \"\",\n            (\n                443\n                if self.mode == NtfyMode.CLOUD\n                else (self.port if self.port else (443 if self.secure else 80))\n            ),\n        ]\n\n        if self.mode == NtfyMode.PRIVATE:\n            if self.auth == NtfyAuth.BASIC:\n                kwargs.extend([\n                    self.user if self.user else None,\n                    self.password if self.password else None,\n                ])\n\n            elif self.token:  # NtfyAuth.TOKEN also\n                kwargs.append(self.token)\n\n        return kwargs\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        default_port = 443 if self.secure else 80\n\n        params = {\n            \"priority\": self.priority,\n            \"mode\": self.mode,\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"auth\": self.auth,\n        }\n\n        if self.avatar_url:\n            params[\"avatar_url\"] = self.avatar_url\n\n        if self.attach is not None:\n            params[\"attach\"] = self.attach\n\n        if self.click is not None:\n            params[\"click\"] = self.click\n\n        if self.delay is not None:\n            params[\"delay\"] = self.delay\n\n        if self.email is not None:\n            params[\"email\"] = self.email\n\n        if self.__tags:\n            params[\"tags\"] = \",\".join(self.__tags)\n\n        if self.__actions:\n            params[\"actions\"] = self.__actions\n\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.auth == NtfyAuth.BASIC:\n            if self.user and self.password:\n                auth = \"{user}:{password}@\".format(\n                    user=NotifyNtfy.quote(self.user, safe=\"\"),\n                    password=self.pprint(\n                        self.password,\n                        privacy,\n                        mode=PrivacyMode.Secret,\n                        safe=\"\",\n                    ),\n                )\n            elif self.user:\n                auth = \"{user}@\".format(\n                    user=NotifyNtfy.quote(self.user, safe=\"\"),\n                )\n\n        elif self.token:  # NtfyAuth.TOKEN also\n            auth = \"{token}@\".format(\n                token=self.pprint(self.token, privacy, safe=\"\"),\n            )\n\n        if self.mode == NtfyMode.PRIVATE:\n            return \"{schema}://{auth}{host}{port}/{targets}?{params}\".format(\n                schema=self.secure_protocol if self.secure else self.protocol,\n                auth=auth,\n                host=self.host,\n                port=(\n                    \"\"\n                    if self.port is None or self.port == default_port\n                    else f\":{self.port}\"\n                ),\n                targets=\"/\".join(\n                    [NotifyNtfy.quote(x, safe=\"\") for x in self.topics]\n                ),\n                params=NotifyNtfy.urlencode(params),\n            )\n\n        else:  # Cloud mode\n            return \"{schema}://{targets}?{params}\".format(\n                schema=self.secure_protocol,\n                targets=\"/\".join(\n                    [NotifyNtfy.quote(x, safe=\"\") for x in self.topics]\n                ),\n                params=NotifyNtfy.urlencode(params),\n            )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return 1 if not self.topics else len(self.topics)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyNtfy.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        if \"attach\" in results[\"qsd\"] and len(results[\"qsd\"][\"attach\"]):\n            results[\"attach\"] = NotifyNtfy.unquote(results[\"qsd\"][\"attach\"])\n            results_ = NotifyBase.parse_url(results[\"attach\"])\n            if results_:\n                results[\"filename\"] = (\n                    None\n                    if results_[\"fullpath\"]\n                    else basename(results_[\"fullpath\"])\n                )\n\n            if \"filename\" in results[\"qsd\"] and len(\n                results[\"qsd\"][\"filename\"]\n            ):\n                results[\"filename\"] = basename(\n                    NotifyNtfy.unquote(results[\"qsd\"][\"filename\"])\n                )\n\n        if \"click\" in results[\"qsd\"] and len(results[\"qsd\"][\"click\"]):\n            results[\"click\"] = NotifyNtfy.unquote(results[\"qsd\"][\"click\"])\n\n        if \"delay\" in results[\"qsd\"] and len(results[\"qsd\"][\"delay\"]):\n            results[\"delay\"] = NotifyNtfy.unquote(results[\"qsd\"][\"delay\"])\n\n        if \"email\" in results[\"qsd\"] and len(results[\"qsd\"][\"email\"]):\n            results[\"email\"] = NotifyNtfy.unquote(results[\"qsd\"][\"email\"])\n\n        if \"tags\" in results[\"qsd\"] and len(results[\"qsd\"][\"tags\"]):\n            results[\"tags\"] = parse_list(\n                NotifyNtfy.unquote(results[\"qsd\"][\"tags\"])\n            )\n\n        if \"actions\" in results[\"qsd\"] and len(results[\"qsd\"][\"actions\"]):\n            results[\"actions\"] = NotifyNtfy.unquote(results[\"qsd\"][\"actions\"])\n\n        # Boolean to include an image or not\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyNtfy.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # Extract avatar url if it was specified\n        if \"avatar_url\" in results[\"qsd\"]:\n            results[\"avatar_url\"] = NotifyNtfy.unquote(\n                results[\"qsd\"][\"avatar_url\"]\n            )\n\n        # Acquire our targets/topics\n        results[\"targets\"] = NotifyNtfy.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyNtfy.parse_list(results[\"qsd\"][\"to\"])\n\n        # Token Specified\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Token presumed to be the one in use\n            results[\"auth\"] = NtfyAuth.TOKEN\n            results[\"token\"] = NotifyNtfy.unquote(results[\"qsd\"][\"token\"])\n\n        # Auth override\n        if \"auth\" in results[\"qsd\"] and results[\"qsd\"][\"auth\"]:\n            results[\"auth\"] = NotifyNtfy.unquote(\n                results[\"qsd\"][\"auth\"].strip().lower()\n            )\n\n        if (\n            not results.get(\"auth\")\n            and results[\"user\"]\n            and not results[\"password\"]\n        ):\n            # We can try to detect the authentication type on the formatting of\n            # the username. Look for tk_.*\n            #\n            # This isn't a surfire way to do things though; it's best to\n            # specify the auth= flag\n            results[\"auth\"] = (\n                NtfyAuth.TOKEN\n                if NTFY_AUTH_DETECT_RE.match(results[\"user\"])\n                else NtfyAuth.BASIC\n            )\n\n        if results.get(\"auth\") == NtfyAuth.TOKEN and not results.get(\"token\"):\n            if results[\"user\"] and not results[\"password\"]:\n                # Make sure we properly set our token\n                results[\"token\"] = NotifyNtfy.unquote(results[\"user\"])\n\n            elif results[\"password\"]:\n                # Make sure we properly set our token\n                results[\"token\"] = NotifyNtfy.unquote(results[\"password\"])\n\n        # Mode override\n        if \"mode\" in results[\"qsd\"] and results[\"qsd\"][\"mode\"]:\n            results[\"mode\"] = NotifyNtfy.unquote(\n                results[\"qsd\"][\"mode\"].strip().lower()\n            )\n\n        else:\n            # We can try to detect the mode based on the validity of the\n            # hostname.\n            #\n            # This isn't a surfire way to do things though; it's best to\n            # specify the mode= flag\n            results[\"mode\"] = (\n                NtfyMode.PRIVATE\n                if (\n                    (\n                        is_hostname(results[\"host\"])\n                        or is_ipaddr(results[\"host\"])\n                    )\n                    and results[\"targets\"]\n                )\n                else NtfyMode.CLOUD\n            )\n\n        if results[\"mode\"] == NtfyMode.CLOUD:\n            # Store first entry as it can be a topic too in this case\n            # But only if we also rule it out not being the words\n            # ntfy.sh itself, something that starts wiht an non-alpha numeric\n            # character:\n            if not NotifyNtfy.__auto_cloud_host.search(results[\"host\"]):\n                # Add it to the front of the list for consistency\n                results[\"targets\"].insert(0, results[\"host\"])\n\n        elif results[\"mode\"] == NtfyMode.PRIVATE and not (\n            is_hostname(results[\"host\"] or is_ipaddr(results[\"host\"]))\n        ):\n            # Invalid Host for NtfyMode.PRIVATE\n            return None\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://ntfy.sh/topic\n        \"\"\"\n\n        # Quick lookup for users who want to just paste\n        # the ntfy.sh url directly into Apprise\n        result = re.match(\n            r\"^(http|ntfy)s?://ntfy\\.sh\"\n            r\"(?P<topics>/[^?]+)?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            mode = f\"mode={NtfyMode.CLOUD}\"\n            return NotifyNtfy.parse_url(\n                \"{schema}://{topics}{params}\".format(\n                    schema=NotifyNtfy.secure_protocol,\n                    topics=(\n                        result.group(\"topics\")\n                        if result.group(\"topics\")\n                        else \"\"\n                    ),\n                    params=(\n                        f\"?{mode}\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\") + f\"&{mode}\"\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/office365.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API Details:\n# https://docs.microsoft.com/en-us/previous-versions/office/\\\n#        office-365-api/?redirectedfrom=MSDN\n\n# Information on sending an email:\n# https://docs.microsoft.com/en-us/graph/api/user-sendmail\\\n#       ?view=graph-rest-1.0&tabs=http\n#\n# Note: One must set up Application Permissions (not Delegated Permissions)\n#       - Scopes required: Mail.Send\n#       - For Large Attachments: Mail.ReadWrite\n#       - For Email Lookups: User.Read.All\n#\nfrom datetime import datetime, timedelta\nimport json\nimport logging\nfrom uuid import uuid4\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyFormat, NotifyType, PersistentStoreMode\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_email, parse_emails, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n\nclass NotifyOffice365(NotifyBase):\n    \"\"\"A wrapper for Office 365 Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Office 365\"\n\n    # The services URL\n    service_url = \"https://office.com/\"\n\n    # The default protocol\n    secure_protocol = (\"azure\", \"o365\")\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/office365/\"\n\n    # URL to Microsoft Graph Server\n    graph_url = \"https://graph.microsoft.com\"\n\n    # Authentication URL\n    auth_url = \"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference\n    storage_mode = PersistentStoreMode.AUTO\n\n    # the maximum size an attachment can be for it to be allowed to be\n    # uploaded inline with the current email going out (one http post)\n    # Anything larger than this and a second PUT request is required to\n    # the outlook server to post the content through reference.\n    # Currently (as of 2025.10.06) this was documented to be 3MB\n    outlook_attachment_inline_max = 3145728\n\n    # Use all the direct application permissions you have configured for your\n    # app. The endpoint should issue a token for the ones associated with the\n    # resource you want to use.\n    # see https://docs.microsoft.com/en-us/azure/active-directory/develop/\\\n    #       v2-permissions-and-consent#the-default-scope\n    scope = \".default\"\n\n    # Default Notify Format\n    notify_format = NotifyFormat.HTML\n\n    # Define object templates\n    templates = (\n        # Send as user (only supported method)\n        \"{schema}://{source}/{tenant}/{client_id}/{secret}\",\n        \"{schema}://{source}/{tenant}/{client_id}/{secret}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"tenant\": {\n                \"name\": _(\"Tenant Domain\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9-]+$\", \"i\"),\n            },\n            \"source\": {\n                \"name\": _(\"Account Email or Object ID\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"client_id\": {\n                \"name\": _(\"Client ID\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9-]+$\", \"i\"),\n            },\n            \"secret\": {\n                \"name\": _(\"Client Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"oauth_id\": {\n                \"alias_of\": \"client_id\",\n            },\n            \"oauth_secret\": {\n                \"alias_of\": \"secret\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        tenant,\n        client_id,\n        secret,\n        source=None,\n        targets=None,\n        cc=None,\n        bcc=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Office 365 Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Tenant identifier\n        self.tenant = validate_regex(\n            tenant, *self.template_tokens[\"tenant\"][\"regex\"]\n        )\n        if not self.tenant:\n            msg = f\"An invalid Office 365 Tenant({tenant}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our email/ObjectID Source\n        self.source = source\n\n        # Client Key (associated with generated OAuth2 Login)\n        self.client_id = validate_regex(\n            client_id, *self.template_tokens[\"client_id\"][\"regex\"]\n        )\n        if not self.client_id:\n            msg = (\n                \"An invalid Office 365 Client OAuth2 ID \"\n                f\"({client_id}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Client Secret (associated with generated OAuth2 Login)\n        self.secret = validate_regex(secret)\n        if not self.secret:\n            msg = (\n                \"An invalid Office 365 Client OAuth2 Secret \"\n                f\"({secret}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # Parse our targets\n        self.targets = []\n\n        if targets:\n            for recipient in parse_emails(targets):\n                # Validate recipients (to:) and drop bad ones:\n                result = is_email(recipient)\n                if result:\n                    # Add our email to our target list\n                    self.targets.append((\n                        result[\"name\"] if result[\"name\"] else False,\n                        result[\"full_email\"],\n                    ))\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid To email ({recipient}) specified.\"\n                )\n\n        else:\n            result = is_email(self.source)\n            if not result:\n                self.logger.warning(\"No Target Office 365 Email Detected\")\n\n            else:\n                # If our target email list is empty we want to add ourselves to\n                # it\n                self.targets.append((False, self.source))\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n            email = is_email(recipient)\n            if email:\n                self.cc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n            email = is_email(recipient)\n            if email:\n                self.bcc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n        # Our token is acquired upon a successful login\n        self.token = None\n\n        # Presume that our token has expired 'now'\n        self.token_expiry = datetime.now()\n\n        # Our email source; we detect this if the source is an ObjectID\n        # If it is unknown we set this to None\n        # User is the email associated with the account\n        self.from_email = self.store.get(\"from\")\n        result = is_email(self.source)\n        if result:\n            self.from_email = result[\"full_email\"]\n            self.from_name = result[\"name\"] or self.store.get(\"name\")\n\n        else:\n            self.from_name = self.store.get(\"name\")\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Office 365 Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\"There are no Email recipients to notify\")\n            return False\n\n        if self.from_email is None:\n            if not self.authenticate():\n                # We could not authenticate ourselves; we're done\n                return False\n\n            # Acquire our from_email\n            url = f\"https://graph.microsoft.com/v1.0/users/{self.source}\"\n            postokay, response = self._fetch(url=url, method=\"GET\")\n            if not postokay:\n                self.logger.warning(\n                    \"Could not acquire From email address; ensure \"\n                    '\"User.Read.All\" Application scope is set!'\n                )\n\n            else:  # Acquire our from_email (if possible)\n                from_email = response.get(\"mail\") or response.get(\n                    \"userPrincipalName\"\n                )\n                result = is_email(from_email)\n                if not result:\n                    self.logger.warning(\n                        \"Could not get From email from the Azure endpoint.\"\n                    )\n\n                    # Prevent re-occuring upstream fetches for info that isn't\n                    # there\n                    self.from_email = False\n\n                else:\n                    # Store our email for future reference\n                    self.from_email = result[\"full_email\"]\n                    self.store.set(\"from\", result[\"full_email\"])\n\n                    self.from_name = response.get(\"displayName\")\n                    if self.from_name:\n                        self.store.set(\"name\", self.from_name)\n\n        # Setup our Content Type\n        content_type = (\n            \"HTML\" if self.notify_format == NotifyFormat.HTML else \"Text\"\n        )\n\n        # Prepare our payload\n        payload = {\n            \"message\": {\n                \"subject\": title,\n                \"body\": {\n                    \"contentType\": content_type,\n                    \"content\": body,\n                },\n            },\n            # Below takes a string (not bool) of either 'true' or 'false'\n            \"saveToSentItems\": \"true\",\n        }\n\n        if self.from_email:\n            # Apply from email if it is known\n            payload[\"message\"].update({\n                \"from\": {\n                    \"emailAddress\": {\n                        \"address\": self.from_email,\n                        \"name\": self.from_name or self.app_id,\n                    }\n                },\n            })\n\n        # Create a copy of the email list\n        emails = list(self.targets)\n\n        # Define our URL to post to\n        url = f\"{self.graph_url}/v1.0/users/{self.source}/sendMail\"\n\n        # Prepare our Draft URL\n        draft_url = f\"{self.graph_url}/v1.0/users/{self.source}/messages\"\n\n        small_attachments = []\n        large_attachments = []\n\n        # draft emails\n        drafts = []\n\n        if attach and self.attachment_support:\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Office 365 attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                if len(attachment) > self.outlook_attachment_inline_max:\n                    # Messages larger then xMB need to be uploaded after; a\n                    # draft email must be prepared; below is our session\n                    large_attachments.append({\n                        \"obj\": attachment,\n                        \"name\": (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                    })\n                    continue\n\n                try:\n                    # Prepare our Attachment in Base64\n                    small_attachments.append({\n                        \"@odata.type\": \"#microsoft.graph.fileAttachment\",\n                        # Name of the attachment (as it should appear in email)\n                        \"name\": (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                        # MIME type of the attachment\n                        \"contentType\": \"attachment.mimetype\",\n                        # Base64 Content\n                        \"contentBytes\": attachment.base64(),\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Office 365 attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending Office 365 attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        if small_attachments:\n            # Store Attachments\n            payload[\"message\"][\"attachments\"] = small_attachments\n\n        while len(emails):\n            # authenticate ourselves if we aren't already; but this function\n            # also tracks if our token we have is still valid and will\n            # re-authenticate ourselves if nessisary.\n            if not self.authenticate():\n                # We could not authenticate ourselves; we're done\n                return False\n\n            # Get our email to notify\n            to_name, to_addr = emails.pop(0)\n\n            # Strip target out of cc list if in To or Bcc\n            cc = self.cc - self.bcc - {to_addr}\n\n            # Strip target out of bcc list if in To\n            bcc = self.bcc - {to_addr}\n\n            # Prepare our email\n            payload[\"message\"][\"toRecipients\"] = [\n                {\"emailAddress\": {\"address\": to_addr}}\n            ]\n            if to_name:\n                # Apply our To Name\n                payload[\"message\"][\"toRecipients\"][0][\"emailAddress\"][\n                    \"name\"\n                ] = to_name\n\n            self.logger.debug(\n                \"{}Email To: {}\".format(\n                    \"Draft\" if large_attachments else \"\", to_addr\n                )\n            )\n\n            if cc:\n                # Prepare our CC list\n                payload[\"message\"][\"ccRecipients\"] = []\n                for addr in cc:\n                    payload_ = {\"address\": addr}\n                    if self.names.get(addr):\n                        payload_[\"name\"] = self.names[addr]\n\n                    # Store our address in our payload\n                    payload[\"message\"][\"ccRecipients\"].append(\n                        {\"emailAddress\": payload_}\n                    )\n\n                self.logger.debug(\n                    \"{}Email Cc: {}\".format(\n                        \"Draft\" if large_attachments else \"\",\n                        \", \".join([\n                            \"{}{}\".format(\n                                (\n                                    \"\"\n                                    if self.names.get(e)\n                                    else f\"{self.names[e]}: \"\n                                ),\n                                e,\n                            )\n                            for e in cc\n                        ]),\n                    )\n                )\n\n            if bcc:\n                # Prepare our CC list\n                payload[\"message\"][\"bccRecipients\"] = []\n                for addr in bcc:\n                    payload_ = {\"address\": addr}\n                    if self.names.get(addr):\n                        payload_[\"name\"] = self.names[addr]\n\n                    # Store our address in our payload\n                    payload[\"message\"][\"bccRecipients\"].append(\n                        {\"emailAddress\": payload_}\n                    )\n\n                self.logger.debug(\n                    \"{}Email Bcc: {}\".format(\n                        \"Draft\" if large_attachments else \"\",\n                        \", \".join([\n                            \"{}{}\".format(\n                                (\n                                    \"\"\n                                    if self.names.get(e)\n                                    else f\"{self.names[e]}: \"\n                                ),\n                                e,\n                            )\n                            for e in bcc\n                        ]),\n                    )\n                )\n\n            # Perform upstream post\n            postokay, response = self._fetch(\n                url=url if not large_attachments else draft_url,\n                payload=payload,\n            )\n\n            # Test if we were okay\n            if not postokay:\n                has_error = True\n\n            elif large_attachments:\n                # We have large attachments now to upload and associate with\n                # our message. We need to prepare a draft message; acquire\n                # the message-id associated with it and then attach the file\n                # via this means.\n\n                # Acquire our Draft ID to work with\n                message_id = response.get(\"id\")\n                if not message_id:\n                    self.logger.warning(\n                        \"Email Draft ID could not be retrieved\"\n                    )\n                    has_error = True\n                    continue\n\n                self.logger.debug(f\"Email Draft ID: {message_id}\")\n                # In future, the below could probably be called via async\n                has_attach_error = False\n                for attachment in large_attachments:\n                    if not self.upload_attachment(\n                        attachment[\"obj\"], message_id, attachment[\"name\"]\n                    ):\n                        self.logger.warning(\n                            \"Could not prepare attachment session for %s\",\n                            attachment[\"name\"],\n                        )\n\n                        has_error = True\n                        has_attach_error = True\n                        # Take early exit\n                        break\n\n                if has_attach_error:\n                    continue\n\n                # Send off our draft\n                attach_url = (\n                    \"https://graph.microsoft.com/v1.0/users/\"\n                    \"{}/messages/{}/send\"\n                )\n\n                attach_url = attach_url.format(\n                    self.source,\n                    message_id,\n                )\n\n                # Trigger our send\n                postokay, response = self._fetch(url=url)\n                if not postokay:\n                    self.logger.warning(\n                        \"Could not send drafted email id: {} \", message_id\n                    )\n                    has_error = True\n                    continue\n\n        # Memory management\n        del small_attachments\n        del large_attachments\n        del drafts\n\n        return not has_error\n\n    def upload_attachment(self, attachment, message_id, name=None):\n        \"\"\"Uploads an attachment to a session.\"\"\"\n\n        # Perform some simple error checking\n        if not attachment:\n            # We could not access the attachment\n            self.logger.error(\n                \"Could not access Office 365 attachment\"\n                f\" {attachment.url(privacy=True)}.\"\n            )\n            return False\n\n        # Our Session URL\n        url = (\n            f\"{self.graph_url}/v1.0/users/{self.source}/message/{message_id}\"\n            + \"/attachments/createUploadSession\"\n        )\n\n        file_size = len(attachment)\n\n        payload = {\n            \"AttachmentItem\": {\n                \"attachmentType\": \"file\",\n                \"name\": (\n                    name\n                    if name\n                    else (\n                        attachment.name\n                        if attachment.name\n                        else f\"{uuid4()!s}.dat\"\n                    )\n                ),\n                # MIME type of the attachment\n                \"contentType\": attachment.mimetype,\n                \"size\": file_size,\n            }\n        }\n\n        if not self.authenticate():\n            # We could not authenticate ourselves; we're done\n            return False\n\n        # Get our Upload URL\n        postokay, response = self._fetch(url, payload)\n        if not postokay:\n            return False\n\n        upload_url = response.get(\"uploadUrl\")\n        if not upload_url:\n            return False\n\n        start_byte = 0\n        postokay = False\n        response = None\n\n        for chunk in attachment.chunk():\n            end_byte = start_byte + len(chunk) - 1\n\n            # Define headers for this chunk\n            headers = {\n                \"User-Agent\": self.app_id,\n                \"Content-Length\": str(len(chunk)),\n                \"Content-Range\": f\"bytes {start_byte}-{end_byte}/{file_size}\",\n            }\n\n            # Upload the chunk\n            postokay, response = self._fetch(\n                upload_url,\n                chunk,\n                headers=headers,\n                content_type=None,\n                method=\"PUT\",\n            )\n            if not postokay:\n                return False\n\n        # Return our Upload URL\n        return postokay\n\n    def authenticate(self):\n        \"\"\"Logs into and acquires us an authentication token to work with.\"\"\"\n\n        if self.token and self.token_expiry > datetime.now():\n            # If we're already authenticated and our token is still valid\n            self.logger.debug(f\"Already authenticate with token {self.token}\")\n            return True\n\n        # If we reach here, we've either expired, or we need to authenticate\n        # for the first time.\n\n        # Prepare our payload\n        payload = {\n            \"grant_type\": \"client_credentials\",\n            \"client_id\": self.client_id,\n            \"client_secret\": self.secret,\n            \"scope\": f\"{self.graph_url}/{self.scope}\",\n        }\n\n        # Prepare our URL\n        url = self.auth_url.format(tenant=self.tenant)\n\n        # A response looks like the following:\n        #    {\n        #       \"token_type\": \"Bearer\",\n        #       \"expires_in\": 3599,\n        #       \"access_token\": \"eyJ0eXAiOiJKV1QiLCJhbGciOiJSzI1NiIsInNBXBP...\"\n        #    }\n        #\n        # Where expires_in defines the number of seconds the key is valid for\n        # before it must be renewed.\n\n        # Alternatively, this could happen too...\n        #    {\n        #      \"error\": \"invalid_scope\",\n        #      \"error_description\": \"AADSTS70011: Blah... Blah Blah... Blah\",\n        #      \"error_codes\": [\n        #        70011\n        #      ],\n        #      \"timestamp\": \"2020-01-09 02:02:12Z\",\n        #      \"trace_id\": \"255d1aef-8c98-452f-ac51-23d051240864\",\n        #      \"correlation_id\": \"fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7\"\n        #    }\n\n        postokay, response = self._fetch(\n            url=url,\n            payload=payload,\n            content_type=\"application/x-www-form-urlencoded\",\n        )\n        if not postokay:\n            return False\n\n        # Reset our token\n        self.token = None\n\n        try:\n            # Extract our time from our response and subtrace 10 seconds from\n            # it to give us some wiggle/grace people to re-authenticate if we\n            # need to\n            self.token_expiry = datetime.now() + timedelta(\n                seconds=int(response.get(\"expires_in\")) - 10\n            )\n\n        except (ValueError, AttributeError, TypeError):\n            # ValueError: expires_in wasn't an integer\n            # TypeError: expires_in was None\n            # AttributeError: we could not extract anything from our response\n            #                object.\n            return False\n\n        # Go ahead and store our token if it's available\n        self.token = response.get(\"access_token\")\n\n        # We're authenticated\n        return bool(self.token)\n\n    def _fetch(\n        self,\n        url,\n        payload=None,\n        headers=None,\n        content_type=\"application/json\",\n        method=\"POST\",\n    ):\n        \"\"\"Wrapper to request object.\"\"\"\n\n        # Prepare our headers:\n        if not headers:\n            headers = {\n                \"User-Agent\": self.app_id,\n                \"Content-Type\": content_type,\n            }\n\n        if self.token:\n            # Are we authenticated?\n            headers[\"Authorization\"] = \"Bearer \" + self.token\n\n        # Default content response object\n        content = {}\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                \"Office 365 %s URL:\"\n                f\" {url} (cert_verify={self.verify_certificate})\",\n                method,\n            )\n            self.logger.debug(\n                \"Office 365 Payload: %s\", sanitize_payload(payload))\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # fetch function\n        req = (\n            requests.post\n            if method == \"POST\"\n            else (requests.put if method == \"PUT\" else requests.get)\n        )\n\n        try:\n            r = req(\n                url,\n                data=(\n                    json.dumps(payload)\n                    if content_type and content_type.endswith(\"/json\")\n                    else payload\n                ),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.created,\n                requests.codes.accepted,\n            ):\n\n                # We had a problem\n                status_str = NotifyOffice365.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Office 365 %s to {}: {}error={}.\".format(\n                        url, \", \" if status_str else \"\", r.status_code\n                    ),\n                    method,\n                )\n\n                # A Response could look like this if a Scope element was not\n                # found:\n                # {\n                #  \"error\": {\n                #     \"code\": \"MissingClaimType\",\n                #     \"message\":\"The token is missing the claim type \\'oid\\'.\",\n                #     \"innerError\": {\n                #       \"oAuthEventOperationId\":\" 7abe20-339f-4659-9381-38f52\",\n                #       \"oAuthEventcV\": \"xsOSpAHSHVm3Tp4SNH5oIA.1.1\",\n                #       \"errorUrl\": \"https://url\",\n                #       \"requestId\": \"2328ea-ec9e-43a8-80f4-164c\",\n                #       \"date\":\"2024-12-01T02:03:13\"\n                #  }}\n                # }\n\n                # Error 403; the below is returned if he User.Read.All\n                #           Application scope is not set and a lookup is\n                #           attempted.\n                # {\n                #   \"error\": {\n                #     \"code\": \"Authorization_RequestDenied\",\n                #     \"message\":\n                #        \"Insufficient privileges to complete the operation.\",\n                #     \"innerError\": {\n                #       \"date\": \"2024-12-06T00:15:57\",\n                #       \"request-id\":\n                #         \"48fdb3e7-2f1a-4f45-a5a0-99b8b851278b\",\n                #         \"client-request-id\": \"48f-2f1a-4f45-a5a0-99b8\"\n                #     }\n                #   }\n                # }\n\n                # Another response type (error 415):\n                # {\n                #  \"error\": {\n                #    \"code\": \"RequestBodyRead\",\n                #    \"message\": \"A missing or empty content type header was \\\n                #        found when trying to read a message. The content \\\n                #        type header is required.\",\n                #  }\n                # }\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                return (False, content)\n\n            try:\n                content = json.loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                content = {}\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"Exception received when sending Office 365 %s to {url}: \",\n                method,\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            return (False, content)\n\n        return (True, content)\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol[0],\n            self.source,\n            self.tenant,\n            self.client_id,\n            self.secret,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Extend our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if self.cc:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                \"{}{}\".format(\n                    \"\" if not self.names.get(e) else f\"{self.names[e]}:\", e\n                )\n                for e in self.cc\n            ])\n\n        if self.bcc:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join([\n                \"{}{}\".format(\n                    \"\" if not self.names.get(e) else f\"{self.names[e]}:\", e\n                )\n                for e in self.bcc\n            ])\n\n        return (\n            \"{schema}://{source}/{tenant}/{client_id}/{secret}\"\n            \"/{targets}/?{params}\".format(\n                schema=self.secure_protocol[0],\n                tenant=self.pprint(self.tenant, privacy, safe=\"\"),\n                # email does not need to be escaped because it should\n                # already be a valid host and username at this point\n                source=self.source,\n                client_id=self.pprint(self.client_id, privacy, safe=\"\"),\n                secret=self.pprint(\n                    self.secret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                targets=\"/\".join([\n                    NotifyOffice365.quote(\n                        \"{}{}\".format(\"\" if not e[0] else f\"{e[0]}:\", e[1]),\n                        safe=\"@\",\n                    )\n                    for e in self.targets\n                ]),\n                params=NotifyOffice365.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Now make a list of all our path entries\n        # We need to read each entry back one at a time in reverse order\n        # where each email found we mark as a target. Once we run out\n        # of targets, the presume the remainder of the entries are part\n        # of the secret key (since it can contain slashes in it)\n        entries = NotifyOffice365.split_path(results[\"fullpath\"])\n\n        # Initialize our tenant\n        results[\"tenant\"] = None\n\n        # Initialize our email\n        results[\"email\"] = None\n\n        # From Email\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            # Extract the sending account's information\n            results[\"source\"] = NotifyOffice365.unquote(results[\"qsd\"][\"from\"])\n\n        # If tenant is occupied, then the user defined makes up our source\n        elif results[\"user\"]:\n            results[\"source\"] = \"{}@{}\".format(\n                NotifyOffice365.unquote(results[\"user\"]),\n                NotifyOffice365.unquote(results[\"host\"]),\n            )\n\n        else:\n            # Object ID instead of email\n            results[\"source\"] = NotifyOffice365.unquote(results[\"host\"])\n\n        # Tenant\n        if \"tenant\" in results[\"qsd\"] and len(results[\"qsd\"][\"tenant\"]):\n            # Extract the Tenant from the argument\n            results[\"tenant\"] = NotifyOffice365.unquote(\n                results[\"qsd\"][\"tenant\"]\n            )\n\n        elif entries:\n            results[\"tenant\"] = NotifyOffice365.unquote(entries.pop(0))\n\n        # OAuth2 ID\n        if \"oauth_id\" in results[\"qsd\"] and len(results[\"qsd\"][\"oauth_id\"]):\n            # Extract the API Key from an argument\n            results[\"client_id\"] = NotifyOffice365.unquote(\n                results[\"qsd\"][\"oauth_id\"]\n            )\n\n        elif entries:\n            # Get our client_id is the first entry on the path\n            results[\"client_id\"] = NotifyOffice365.unquote(entries.pop(0))\n\n        #\n        # Prepare our target listing\n        #\n        results[\"targets\"] = []\n        while entries:\n            # Pop the last entry\n            entry = NotifyOffice365.unquote(entries.pop(-1))\n\n            if is_email(entry):\n                # Store our email and move on\n                results[\"targets\"].append(entry)\n                continue\n\n            # If we reach here, the entry we just popped is part of the secret\n            # key, so put it back\n            entries.append(NotifyOffice365.quote(entry, safe=\"\"))\n\n            # We're done\n            break\n\n        # OAuth2 Secret\n        if \"oauth_secret\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"oauth_secret\"]\n        ):\n            # Extract the API Secret from an argument\n            results[\"secret\"] = NotifyOffice365.unquote(\n                results[\"qsd\"][\"oauth_secret\"]\n            )\n\n        else:\n            # Assemble our secret key which is a combination of the host\n            # followed by all entries in the full path that follow up until\n            # the first email\n            results[\"secret\"] = \"/\".join(\n                [NotifyOffice365.unquote(x) for x in entries]\n            )\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyOffice365.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = results[\"qsd\"][\"cc\"]\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = results[\"qsd\"][\"bcc\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/one_signal.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# One Signal requires that you've signed up with the service and\n# generated yourself an API Key and APP ID.\n\n# Sources:\n#  - https://documentation.onesignal.com/docs/accounts-and-keys\n#  - https://documentation.onesignal.com/reference/create-notification\n\nfrom itertools import chain\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.base64 import decode_b64_dict, encode_b64_dict\nfrom ..utils.parse import is_email, parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n\nclass OneSignalCategory:\n    \"\"\"We define the different category types that we can notify via\n    OneSignal.\"\"\"\n\n    PLAYER = \"include_player_ids\"\n    EMAIL = \"include_email_tokens\"\n    USER = \"include_external_user_ids\"\n    SEGMENT = \"included_segments\"\n\n\nONESIGNAL_CATEGORIES = (\n    OneSignalCategory.PLAYER,\n    OneSignalCategory.EMAIL,\n    OneSignalCategory.USER,\n    OneSignalCategory.SEGMENT,\n)\n\n\nclass NotifyOneSignal(NotifyBase):\n    \"\"\"A wrapper for OneSignal Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"OneSignal\"\n\n    # The services URL\n    service_url = \"https://onesignal.com\"\n\n    # The default protocol\n    secure_protocol = \"onesignal\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/onesignal/\"\n\n    # Notification\n    notify_url = \"https://api.onesignal.com/notifications\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # The maximum allowable batch sizes per message\n    default_batch_size = 2000\n\n    # Define object templates\n    templates = (\n        \"{schema}://{app}@{apikey}/{targets}\",\n        \"{schema}://{template}:{app}@{apikey}/{targets}\",\n    )\n\n    # Define our template\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            # The App_ID is a UUID\n            # such as: 8250eaf6-1a58-489e-b136-7c74a864b434\n            \"app\": {\n                \"name\": _(\"App ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"template\": {\n                \"name\": _(\"Template\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_player\": {\n                \"name\": _(\"Target Player ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"target_segment\": {\n                \"name\": _(\"Include Segment\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"template\": {\n                \"alias_of\": \"template\",\n            },\n            \"subtitle\": {\n                \"name\": _(\"Subtitle\"),\n                \"type\": \"string\",\n            },\n            \"language\": {\n                \"name\": _(\"Language\"),\n                \"type\": \"string\",\n                \"default\": \"en\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"contents\": {\n                \"name\": _(\"Enable Contents\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"use_contents\",\n            },\n            \"decode\": {\n                \"name\": _(\"Decode Template Args\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"decode_tpl_args\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    # Define our token control\n    template_kwargs = {\n        \"custom\": {\n            \"name\": _(\"Custom Data\"),\n            \"prefix\": \":\",\n        },\n        \"postback\": {\n            \"name\": _(\"Postback Data\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self,\n        app,\n        apikey,\n        targets=None,\n        include_image=True,\n        template=None,\n        subtitle=None,\n        language=None,\n        batch=None,\n        use_contents=None,\n        decode_tpl_args=None,\n        custom=None,\n        postback=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize OneSignal.\"\"\"\n        super().__init__(**kwargs)\n\n        # The apikey associated with the account\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid OneSignal API key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The App ID associated with the account\n        self.app = validate_regex(app)\n        if not self.app:\n            msg = f\"An invalid OneSignal Application ID ({app}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prepare Batch Mode Flag\n        self.batch_size = (\n            self.default_batch_size\n            if (\n                batch\n                if batch is not None\n                else self.template_args[\"batch\"][\"default\"]\n            )\n            else 1\n        )\n\n        # Prepare Use Contents Flag\n        self.use_contents = bool(\n            use_contents\n            if use_contents is not None\n            else self.template_args[\"contents\"][\"default\"]\n        )\n\n        # Prepare Decode Template Arguments Flag\n        self.decode_tpl_args = bool(\n            decode_tpl_args\n            if decode_tpl_args is not None\n            else self.template_args[\"decode\"][\"default\"]\n        )\n\n        # Place a thumbnail image inline with the message body\n        self.include_image = include_image\n\n        # Our Assorted Types of Targets\n        self.targets = {\n            OneSignalCategory.PLAYER: [],\n            OneSignalCategory.EMAIL: [],\n            OneSignalCategory.USER: [],\n            OneSignalCategory.SEGMENT: [],\n        }\n\n        # Assign our template (if defined)\n        self.template_id = template\n\n        # Assign our subtitle (if defined)\n        self.subtitle = subtitle\n\n        # Our Language\n        self.language = (\n            language.strip().lower()[0:2]\n            if language\n            else NotifyOneSignal.template_args[\"language\"][\"default\"]\n        )\n\n        if not self.language or len(self.language) != 2:\n            msg = f\"An invalid OneSignal Language ({language}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Sort our targets\n        for target_ in parse_list(targets):\n            target = target_.strip()\n            if len(target) < 2:\n                self.logger.debug(f\"Ignoring OneSignal Entry: {target}\")\n                continue\n\n            if target.startswith(\n                NotifyOneSignal.template_tokens[\"target_user\"][\"prefix\"]\n            ):\n\n                self.targets[OneSignalCategory.USER].append(target)\n                self.logger.debug(\n                    \"Detected OneSignal UserID:\"\n                    f\" {self.targets[OneSignalCategory.USER][-1]}\"\n                )\n                continue\n\n            if target.startswith(\n                NotifyOneSignal.template_tokens[\"target_segment\"][\"prefix\"]\n            ):\n\n                self.targets[OneSignalCategory.SEGMENT].append(target)\n                self.logger.debug(\n                    \"Detected OneSignal Include Segment:\"\n                    f\" {self.targets[OneSignalCategory.SEGMENT][-1]}\"\n                )\n                continue\n\n            result = is_email(target)\n            if result:\n                self.targets[OneSignalCategory.EMAIL].append(\n                    result[\"full_email\"]\n                )\n                self.logger.debug(\n                    \"Detected OneSignal Email:\"\n                    f\" {self.targets[OneSignalCategory.EMAIL][-1]}\"\n                )\n\n            else:\n                # Add element as Player ID\n                self.targets[OneSignalCategory.PLAYER].append(target)\n                self.logger.debug(\n                    \"Detected OneSignal Player ID:\"\n                    f\" {self.targets[OneSignalCategory.PLAYER][-1]}\"\n                )\n\n        # Custom Data\n        self.custom_data = {}\n        if custom and isinstance(custom, dict):\n            if self.decode_tpl_args:\n                custom = decode_b64_dict(custom)\n\n            self.custom_data.update(custom)\n\n        elif custom:\n            msg = (\n                \"The specified OneSignal Custom Data \"\n                f\"({custom}) are not identified as a dictionary.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Postback Data\n        self.postback_data = {}\n        if postback and isinstance(postback, dict):\n            self.postback_data.update(postback)\n\n        elif postback:\n            msg = (\n                \"The specified OneSignal Postback Data \"\n                f\"({postback}) are not identified as a dictionary.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform OneSignal Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json; charset=utf-8\",\n            \"Authorization\": f\"Basic {self.apikey}\",\n        }\n\n        has_error = False\n        sent_count = 0\n\n        payload = {\n            \"app_id\": self.app,\n            \"contents\": {\n                self.language: body,\n            },\n            # Sending true wakes your app from background to run custom native\n            # code (Apple interprets this as content-available=1).\n            # Note: Not applicable if the app is in the \"force-quit\" state\n            #      (i.e app was swiped away). Omit the contents field to\n            #      prevent displaying a visible notification.\n            \"content_available\": True,\n        }\n\n        if self.template_id:\n            # Store template information\n            payload[\"template_id\"] = self.template_id\n\n            if not self.use_contents:\n                # Only if a template is defined can contents be removed\n                del payload[\"contents\"]\n\n        # Set our data if defined\n        if self.custom_data:\n            payload.update({\n                \"custom_data\": self.custom_data,\n            })\n\n        # Set our postback data if defined\n        if self.postback_data:\n            payload.update({\n                \"data\": self.postback_data,\n            })\n\n        if title:\n            # Display our title if defined\n            payload.update(\n                {\n                    \"headings\": {\n                        self.language: title,\n                    }\n                }\n            )\n\n        if self.subtitle:\n            payload.update({\n                \"subtitle\": {\n                    self.language: self.subtitle,\n                },\n            })\n\n        # Acquire our large_icon image URL (if set)\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n        if image_url:\n            payload[\"large_icon\"] = image_url\n\n        # Acquire our small_icon image URL (if set)\n        image_url = (\n            None\n            if not self.include_image\n            else self.image_url(notify_type, image_size=NotifyImageSize.XY_32)\n        )\n        if image_url:\n            payload[\"small_icon\"] = image_url\n\n        for category in ONESIGNAL_CATEGORIES:\n            # Create a pointer to our list of targets for specified category\n            targets = self.targets[category]\n            for index in range(0, len(targets), self.batch_size):\n                payload[category] = targets[index : index + self.batch_size]\n\n                # Track our sent count\n                sent_count += len(payload[category])\n\n                self.logger.debug(\n                    \"OneSignal POST URL:\"\n                    f\" {self.notify_url} \"\n                    f\"(cert_verify={self.verify_certificate!r})\"\n                )\n                self.logger.debug(f\"OneSignal Payload: {payload!s}\")\n\n                # Always call throttle before any remote server i/o is made\n                self.throttle()\n                try:\n                    r = requests.post(\n                        self.notify_url,\n                        data=dumps(payload),\n                        headers=headers,\n                        verify=self.verify_certificate,\n                        timeout=self.request_timeout,\n                    )\n                    if r.status_code not in (\n                        requests.codes.ok,\n                        requests.codes.no_content,\n                    ):\n                        # We had a problem\n                        status_str = NotifyOneSignal.http_response_code_lookup(\n                            r.status_code\n                        )\n\n                        self.logger.warning(\n                            \"Failed to send OneSignal notification: \"\n                            \"{}{}error={}.\".format(\n                                status_str,\n                                \", \" if status_str else \"\",\n                                r.status_code,\n                            )\n                        )\n\n                        self.logger.debug(\n                            \"Response Details:\\r\\n%r\",\n                            (r.content or b\"\")[:2000])\n\n                        has_error = True\n\n                    else:\n                        self.logger.info(\"Sent OneSignal notification.\")\n\n                except requests.RequestException as e:\n                    self.logger.warning(\n                        \"A Connection error occurred sending OneSignal \"\n                        \"notification.\"\n                    )\n                    self.logger.debug(\"Socket Exception: %s\", e)\n\n                    has_error = True\n\n        if not sent_count:\n            # There is no one to notify; we need to capture this and not\n            # return a valid\n            self.logger.warning(\"There are no OneSignal targets to notify\")\n            return False\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.template_id,\n            self.app,\n            self.apikey,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"batch\": \"yes\" if self.batch_size > 1 else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        custom_data, needs_decoding = encode_b64_dict(self.custom_data)\n        # custom_data, needs_decoding = self.custom_data, False\n        # Save our template data\n        params.update({f\":{k}\": v for k, v in custom_data.items()})\n\n        # Save our postback data\n        params.update({f\"+{k}\": v for k, v in self.postback_data.items()})\n\n        if self.use_contents != self.template_args[\"contents\"][\"default\"]:\n            params[\"contents\"] = \"yes\" if self.use_contents else \"no\"\n\n        if (\n            self.decode_tpl_args != self.template_args[\"decode\"][\"default\"]\n            or needs_decoding\n        ):\n            params[\"decode\"] = (\n                \"yes\" if (self.decode_tpl_args or needs_decoding) else \"no\"\n            )\n\n        return \"{schema}://{tp_id}{app}@{apikey}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            tp_id=(\n                \"{}:\".format(self.pprint(self.template_id, privacy, safe=\"\"))\n                if self.template_id\n                else \"\"\n            ),\n            app=self.pprint(self.app, privacy, safe=\"\"),\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                chain(\n                    [\n                        NotifyOneSignal.quote(x)\n                        for x in self.targets[OneSignalCategory.PLAYER]\n                    ],\n                    [\n                        NotifyOneSignal.quote(x)\n                        for x in self.targets[OneSignalCategory.EMAIL]\n                    ],\n                    [\n                        NotifyOneSignal.quote(\n                            \"{}{}\".format(\n                                NotifyOneSignal.template_tokens[\"target_user\"][\n                                    \"prefix\"\n                                ],\n                                x,\n                            ),\n                            safe=\"\",\n                        )\n                        for x in self.targets[OneSignalCategory.USER]\n                    ],\n                    [\n                        NotifyOneSignal.quote(\n                            \"{}{}\".format(\n                                NotifyOneSignal.template_tokens[\n                                    \"target_segment\"\n                                ][\"prefix\"],\n                                x,\n                            ),\n                            safe=\"\",\n                        )\n                        for x in self.targets[OneSignalCategory.SEGMENT]\n                    ],\n                )\n            ),\n            params=NotifyOneSignal.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        if self.batch_size > 1:\n            # Batches can only be sent by group (you can't combine groups into\n            # a single batch)\n            total_targets = 0\n            for _k, m in self.targets.items():\n                targets = len(m)\n                total_targets += int(targets / self.batch_size) + (\n                    1 if targets % self.batch_size else 0\n                )\n            return total_targets\n\n        # Normal batch count; just count the targets\n        return sum(len(m) for _, m in self.targets.items())\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if not results.get(\"password\"):\n            # The APP ID identifier associated with the account\n            results[\"app\"] = NotifyOneSignal.unquote(results[\"user\"])\n\n        else:\n            # The APP ID identifier associated with the account\n            results[\"app\"] = NotifyOneSignal.unquote(results[\"password\"])\n            # The Template ID\n            results[\"template\"] = NotifyOneSignal.unquote(results[\"user\"])\n\n        # Get Image Boolean (if set)\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyOneSignal.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # Get Batch Boolean (if set)\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyOneSignal.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        # Get Use Contents Boolean (if set)\n        results[\"use_contents\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"contents\",\n                NotifyOneSignal.template_args[\"contents\"][\"default\"],\n            )\n        )\n\n        # Get Use Contents Boolean (if set)\n        results[\"decode_tpl_args\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"decode\", NotifyOneSignal.template_args[\"decode\"][\"default\"]\n            )\n        )\n\n        # The API Key is stored in the hostname\n        results[\"apikey\"] = NotifyOneSignal.unquote(results[\"host\"])\n\n        # Get our Targets\n        results[\"targets\"] = NotifyOneSignal.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyOneSignal.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        if \"app\" in results[\"qsd\"] and len(results[\"qsd\"][\"app\"]):\n            results[\"app\"] = NotifyOneSignal.unquote(results[\"qsd\"][\"app\"])\n\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            results[\"apikey\"] = NotifyOneSignal.unquote(\n                results[\"qsd\"][\"apikey\"]\n            )\n\n        if \"template\" in results[\"qsd\"] and len(results[\"qsd\"][\"template\"]):\n            results[\"template\"] = NotifyOneSignal.unquote(\n                results[\"qsd\"][\"template\"]\n            )\n\n        if \"subtitle\" in results[\"qsd\"] and len(results[\"qsd\"][\"subtitle\"]):\n            results[\"subtitle\"] = NotifyOneSignal.unquote(\n                results[\"qsd\"][\"subtitle\"]\n            )\n\n        if \"lang\" in results[\"qsd\"] and len(results[\"qsd\"][\"lang\"]):\n            results[\"language\"] = NotifyOneSignal.unquote(\n                results[\"qsd\"][\"lang\"]\n            )\n\n        # Store our custom data\n        results[\"custom\"] = results[\"qsd:\"]\n\n        # Store our postback data\n        results[\"postback\"] = results[\"qsd+\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/opsgenie.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Signup @ https://www.opsgenie.com\n#\n# Generate your Integration API Key\n#   https://app.opsgenie.com/settings/integration/add/API/\n\n# Knowing this, you can build your Opsgenie URL as follows:\n#  opsgenie://{apikey}/\n#  opsgenie://{apikey}/@{user}\n#  opsgenie://{apikey}/*{schedule}\n#  opsgenie://{apikey}/^{escalation}\n#  opsgenie://{apikey}/#{team}\n#\n# You can mix and match what you want to notify freely\n#  opsgenie://{apikey}/@{user}/#{team}/*{schedule}/^{escalation}\n#\n# If no target prefix is specified, then it is assumed to be a user.\n#\n# API Documentation: https://docs.opsgenie.com/docs/alert-api\n# API Integration Docs: https://docs.opsgenie.com/docs/api-integration\n\nimport hashlib\nfrom json import dumps, loads\n\nimport requests\n\nfrom ..common import NOTIFY_TYPES, NotifyType, PersistentStoreMode\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_uuid, parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n\nclass OpsgenieCategory(NotifyBase):\n    \"\"\"We define the different category types that we can notify.\"\"\"\n\n    USER = \"user\"\n    SCHEDULE = \"schedule\"\n    ESCALATION = \"escalation\"\n    TEAM = \"team\"\n\n\nOPSGENIE_CATEGORIES = (\n    OpsgenieCategory.USER,\n    OpsgenieCategory.SCHEDULE,\n    OpsgenieCategory.ESCALATION,\n    OpsgenieCategory.TEAM,\n)\n\n\nclass OpsgenieAlertAction:\n    \"\"\"Defines the supported actions.\"\"\"\n\n    # Use mapping (specify :key=arg to over-ride)\n    MAP = \"map\"\n\n    # Create new alert (default)\n    NEW = \"new\"\n\n    # Close Alert\n    CLOSE = \"close\"\n\n    # Delete Alert\n    DELETE = \"delete\"\n\n    # Acknowledge Alert\n    ACKNOWLEDGE = \"acknowledge\"\n\n    # Add note to alert\n    NOTE = \"note\"\n\n\nOPSGENIE_ACTIONS = (\n    OpsgenieAlertAction.MAP,\n    OpsgenieAlertAction.NEW,\n    OpsgenieAlertAction.CLOSE,\n    OpsgenieAlertAction.DELETE,\n    OpsgenieAlertAction.ACKNOWLEDGE,\n    OpsgenieAlertAction.NOTE,\n)\n\n# Map all support Apprise Categories to Opsgenie Categories\nOPSGENIE_ALERT_MAP = {\n    NotifyType.INFO: OpsgenieAlertAction.CLOSE,\n    NotifyType.SUCCESS: OpsgenieAlertAction.CLOSE,\n    NotifyType.WARNING: OpsgenieAlertAction.NEW,\n    NotifyType.FAILURE: OpsgenieAlertAction.NEW,\n}\n\n\n# Regions\nclass OpsgenieRegion:\n    US = \"us\"\n    EU = \"eu\"\n\n\n# Opsgenie APIs\nOPSGENIE_API_LOOKUP = {\n    OpsgenieRegion.US: \"https://api.opsgenie.com/v2/alerts\",\n    OpsgenieRegion.EU: \"https://api.eu.opsgenie.com/v2/alerts\",\n}\n\n# A List of our regions we can use for verification\nOPSGENIE_REGIONS = (\n    OpsgenieRegion.US,\n    OpsgenieRegion.EU,\n)\n\n\n# Priorities\nclass OpsgeniePriority:\n    LOW = 1\n    MODERATE = 2\n    NORMAL = 3\n    HIGH = 4\n    EMERGENCY = 5\n\n\nOPSGENIE_PRIORITIES = {\n    # Note: This also acts as a reverse lookup mapping\n    OpsgeniePriority.LOW: \"low\",\n    OpsgeniePriority.MODERATE: \"moderate\",\n    OpsgeniePriority.NORMAL: \"normal\",\n    OpsgeniePriority.HIGH: \"high\",\n    OpsgeniePriority.EMERGENCY: \"emergency\",\n}\n\nOPSGENIE_PRIORITY_MAP = {\n    # Maps against string 'low'\n    \"l\": OpsgeniePriority.LOW,\n    # Maps against string 'moderate'\n    \"m\": OpsgeniePriority.MODERATE,\n    # Maps against string 'normal'\n    \"n\": OpsgeniePriority.NORMAL,\n    # Maps against string 'high'\n    \"h\": OpsgeniePriority.HIGH,\n    # Maps against string 'emergency'\n    \"e\": OpsgeniePriority.EMERGENCY,\n    # Entries to additionally support (so more like Opsgenie's API)\n    \"1\": OpsgeniePriority.LOW,\n    \"2\": OpsgeniePriority.MODERATE,\n    \"3\": OpsgeniePriority.NORMAL,\n    \"4\": OpsgeniePriority.HIGH,\n    \"5\": OpsgeniePriority.EMERGENCY,\n    # Support p-prefix\n    \"p1\": OpsgeniePriority.LOW,\n    \"p2\": OpsgeniePriority.MODERATE,\n    \"p3\": OpsgeniePriority.NORMAL,\n    \"p4\": OpsgeniePriority.HIGH,\n    \"p5\": OpsgeniePriority.EMERGENCY,\n}\n\n\nclass NotifyOpsgenie(NotifyBase):\n    \"\"\"A wrapper for Opsgenie Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Opsgenie\"\n\n    # The services URL\n    service_url = \"https://opsgenie.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"opsgenie\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/opsgenie/\"\n\n    # The maximum length of the body\n    body_maxlen = 15000\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference\n    storage_mode = PersistentStoreMode.AUTO\n\n    # If we don't have the specified min length, then we don't bother using\n    # the body directive\n    opsgenie_body_minlen = 130\n\n    # The default region to use if one isn't otherwise specified\n    opsgenie_default_region = OpsgenieRegion.US\n\n    # The maximum allowable targets within a notification\n    default_batch_size = 50\n\n    # Defines our default message mapping\n    opsgenie_message_map = {\n        # Add a note to existing alert\n        NotifyType.INFO: OpsgenieAlertAction.NOTE,\n        # Close existing alert\n        NotifyType.SUCCESS: OpsgenieAlertAction.CLOSE,\n        # Create notice\n        NotifyType.WARNING: OpsgenieAlertAction.NEW,\n        # Create notice\n        NotifyType.FAILURE: OpsgenieAlertAction.NEW,\n    }\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}\",\n        \"{schema}://{user}@{apikey}\",\n        \"{schema}://{apikey}/{targets}\",\n        \"{schema}://{user}@{apikey}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"target_escalation\": {\n                \"name\": _(\"Target Escalation\"),\n                \"prefix\": \"^\",\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_schedule\": {\n                \"name\": _(\"Target Schedule\"),\n                \"type\": \"string\",\n                \"prefix\": \"*\",\n                \"map_to\": \"targets\",\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"target_team\": {\n                \"name\": _(\"Target Team\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets \"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"region\": {\n                \"name\": _(\"Region Name\"),\n                \"type\": \"choice:string\",\n                \"values\": OPSGENIE_REGIONS,\n                \"default\": OpsgenieRegion.US,\n                \"map_to\": \"region_name\",\n            },\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": OPSGENIE_PRIORITIES,\n                \"default\": OpsgeniePriority.NORMAL,\n            },\n            \"entity\": {\n                \"name\": _(\"Entity\"),\n                \"type\": \"string\",\n            },\n            \"alias\": {\n                \"name\": _(\"Alias\"),\n                \"type\": \"string\",\n            },\n            \"tags\": {\n                \"name\": _(\"Tags\"),\n                \"type\": \"string\",\n            },\n            \"action\": {\n                \"name\": _(\"Action\"),\n                \"type\": \"choice:string\",\n                \"values\": OPSGENIE_ACTIONS,\n                \"default\": OPSGENIE_ACTIONS[0],\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    # Map of key-value pairs to use as custom properties of the alert.\n    template_kwargs = {\n        \"details\": {\n            \"name\": _(\"Details\"),\n            \"prefix\": \"+\",\n        },\n        \"mapping\": {\n            \"name\": _(\"Action Mapping\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        apikey,\n        targets,\n        region_name=None,\n        details=None,\n        priority=None,\n        alias=None,\n        entity=None,\n        batch=False,\n        tags=None,\n        action=None,\n        mapping=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Opsgenie Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid Opsgenie API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The Priority of the message\n        self.priority = (\n            NotifyOpsgenie.template_args[\"priority\"][\"default\"]\n            if not priority\n            else next(\n                (\n                    v\n                    for k, v in OPSGENIE_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyOpsgenie.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        # Store our region\n        try:\n            self.region_name = (\n                self.opsgenie_default_region\n                if region_name is None\n                else region_name.lower()\n            )\n\n            if self.region_name not in OPSGENIE_REGIONS:\n                # allow the outer except to handle this common response\n                raise\n        except:\n            # Invalid region specified\n            msg = f\"The Opsgenie region specified ({region_name}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        if action and isinstance(action, str):\n            self.action = next(\n                (a for a in OPSGENIE_ACTIONS if a.startswith(action)), None\n            )\n            if self.action not in OPSGENIE_ACTIONS:\n                msg = f\"The Opsgenie action specified ({action}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.action = self.template_args[\"action\"][\"default\"]\n\n        # Store our mappings\n        self.mapping = self.opsgenie_message_map.copy()\n        if mapping and isinstance(mapping, dict):\n            for k_, v_ in mapping.items():\n                # Get our mapping\n                k = next((t for t in NOTIFY_TYPES if t.startswith(k_)), None)\n                if not k:\n                    msg = (\n                        f\"The Opsgenie mapping key specified ({k_}) \"\n                        \"is invalid.\"\n                    )\n                    self.logger.warning(msg)\n                    raise TypeError(msg)\n\n                v_lower = v_.lower()\n                v = next(\n                    (\n                        v\n                        for v in OPSGENIE_ACTIONS[1:]\n                        if v.startswith(v_lower)\n                    ),\n                    None,\n                )\n                if not v:\n                    msg = (\n                        f\"The Opsgenie mapping value (assigned to {k}) \"\n                        f\"specified ({v_}) is invalid.\"\n                    )\n                    self.logger.warning(msg)\n                    raise TypeError(msg)\n\n                # Update our mapping\n                self.mapping[k] = v\n\n        self.details = {}\n        if details:\n            # Store our extra details\n            self.details.update(details)\n\n        # Prepare Batch Mode Flag\n        self.batch_size = self.default_batch_size if batch else 1\n\n        # Assign our tags (if defined)\n        self.__tags = parse_list(tags)\n\n        # Assign our entity (if defined)\n        self.entity = entity\n\n        # Assign our alias (if defined)\n        self.alias = alias\n\n        # Initialize our Targets\n        self.targets = []\n\n        # Sort our targets\n        for target_ in parse_list(targets):\n            target = target_.strip()\n            if len(target) < 2:\n                self.logger.debug(f\"Ignoring Opsgenie Entry: {target}\")\n                continue\n\n            if target.startswith(\n                NotifyOpsgenie.template_tokens[\"target_team\"][\"prefix\"]\n            ):\n\n                self.targets.append(\n                    {\"type\": OpsgenieCategory.TEAM, \"id\": target[1:]}\n                    if is_uuid(target[1:])\n                    else {\"type\": OpsgenieCategory.TEAM, \"name\": target[1:]}\n                )\n\n            elif target.startswith(\n                NotifyOpsgenie.template_tokens[\"target_schedule\"][\"prefix\"]\n            ):\n\n                self.targets.append(\n                    {\"type\": OpsgenieCategory.SCHEDULE, \"id\": target[1:]}\n                    if is_uuid(target[1:])\n                    else {\n                        \"type\": OpsgenieCategory.SCHEDULE,\n                        \"name\": target[1:],\n                    }\n                )\n\n            elif target.startswith(\n                NotifyOpsgenie.template_tokens[\"target_escalation\"][\"prefix\"]\n            ):\n\n                self.targets.append(\n                    {\"type\": OpsgenieCategory.ESCALATION, \"id\": target[1:]}\n                    if is_uuid(target[1:])\n                    else {\n                        \"type\": OpsgenieCategory.ESCALATION,\n                        \"name\": target[1:],\n                    }\n                )\n\n            elif target.startswith(\n                NotifyOpsgenie.template_tokens[\"target_user\"][\"prefix\"]\n            ):\n\n                self.targets.append(\n                    {\"type\": OpsgenieCategory.USER, \"id\": target[1:]}\n                    if is_uuid(target[1:])\n                    else {\n                        \"type\": OpsgenieCategory.USER,\n                        \"username\": target[1:],\n                    }\n                )\n\n            else:\n                # Ambiguious entry; treat it as a user but not before\n                # displaying a warning to the end user first:\n                self.logger.debug(\n                    \"Treating ambigious Opsgenie target %s as a user\", target\n                )\n                self.targets.append(\n                    {\"type\": OpsgenieCategory.USER, \"id\": target}\n                    if is_uuid(target)\n                    else {\"type\": OpsgenieCategory.USER, \"username\": target}\n                )\n\n    def _fetch(self, method, url, payload, params=None):\n        \"\"\"Performs server retrieval/update and returns JSON Response.\"\"\"\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"GenieKey {self.apikey}\",\n        }\n\n        # Some Debug Logging\n        self.logger.debug(\n            f\"Opsgenie POST URL: {url} (cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"Opsgenie Payload: {payload}\")\n\n        # Initialize our response object\n        content = {}\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = method(\n                url,\n                data=dumps(payload),\n                params=params,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            # A Response might look like:\n            # {\n            #     \"result\": \"Request will be processed\",\n            #     \"took\": 0.302,\n            #     \"requestId\": \"43a29c5c-3dbf-4fa4-9c26-f4f71023e120\"\n            # }\n\n            try:\n                # Update our response object\n                content = loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                content = {}\n\n            if r.status_code not in (\n                requests.codes.accepted,\n                requests.codes.ok,\n            ):\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Opsgenie notification:\"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return (False, content.get(\"requestId\"))\n\n            # If we reach here; the message was sent\n            self.logger.info(\"Sent Opsgenie notification\")\n\n            return (True, content.get(\"requestId\"))\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Opsgenie notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n        return (False, content.get(\"requestId\"))\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Opsgenie Notification.\"\"\"\n\n        # Get our Opsgenie Action\n        action = (\n            OPSGENIE_ALERT_MAP[notify_type]\n            if self.action == OpsgenieAlertAction.MAP\n            else self.action\n        )\n\n        # Prepare our URL as it's based on our hostname\n        notify_url = OPSGENIE_API_LOOKUP[self.region_name]\n\n        # Initialize our has_error flag\n        has_error = False\n\n        # Default method is to post\n        method = requests.post\n\n        # For indexing in persistent store\n        key = hashlib.sha1(\n            (\n                self.entity\n                if self.entity\n                else (\n                    self.alias\n                    if self.alias\n                    else (title if title else self.app_id)\n                )\n            ).encode(\"utf-8\")\n        ).hexdigest()[0:10]\n\n        # Get our Opsgenie Request IDs\n        request_ids = self.store.get(key, [])\n        if not isinstance(request_ids, list):\n            request_ids = []\n\n        if action == OpsgenieAlertAction.NEW:\n            # Create a copy ouf our details object\n            details = self.details.copy()\n            if \"type\" not in details:\n                details[\"type\"] = notify_type.value\n\n            # Use body if title not set\n            title_body = title if title else body\n\n            # Prepare our payload\n            payload = {\n                \"source\": self.app_desc,\n                \"message\": title_body,\n                \"description\": body,\n                \"details\": details,\n                \"priority\": f\"P{self.priority}\",\n            }\n\n            # Use our body directive if we exceed the minimum message\n            # limitation\n            if len(payload[\"message\"]) > self.opsgenie_body_minlen:\n                payload[\"message\"] = (\n                    f\"{title_body[:self.opsgenie_body_minlen - 3]}...\"\n                )\n\n            if self.__tags:\n                payload[\"tags\"] = self.__tags\n\n            if self.entity:\n                payload[\"entity\"] = self.entity\n\n            if self.alias:\n                payload[\"alias\"] = self.alias\n\n            if self.user:\n                payload[\"user\"] = self.user\n\n            # reset our request IDs - we will re-populate them\n            request_ids = []\n\n            length = len(self.targets) if self.targets else 1\n            for index in range(0, length, self.batch_size):\n                if self.targets:\n                    # If there were no targets identified, then we simply\n                    # just iterate once without the responders set\n                    payload[\"responders\"] = self.targets[\n                        index : index + self.batch_size\n                    ]\n\n                # Perform our post\n                success, request_id = self._fetch(method, notify_url, payload)\n\n                if success and request_id:\n                    # Save our response\n                    request_ids.append(request_id)\n\n                else:\n                    has_error = True\n\n            # Store our entries for a maximum of 60 days\n            self.store.set(key, request_ids, expires=60 * 60 * 24 * 60)\n\n        elif request_ids:\n            # Prepare our payload\n            payload = {\n                \"source\": self.app_desc,\n                \"note\": body,\n            }\n\n            if self.user:\n                payload[\"user\"] = self.user\n\n            # Prepare our Identifier type\n            params = {\n                \"identifierType\": \"id\",\n            }\n\n            for request_id in request_ids:\n                if action == OpsgenieAlertAction.DELETE:\n                    # Update our URL\n                    url = f\"{notify_url}/{request_id}\"\n                    method = requests.delete\n\n                elif action == OpsgenieAlertAction.ACKNOWLEDGE:\n                    url = f\"{notify_url}/{request_id}/acknowledge\"\n\n                elif action == OpsgenieAlertAction.CLOSE:\n                    url = f\"{notify_url}/{request_id}/close\"\n\n                else:  # action == OpsgenieAlertAction.CLOSE:\n                    url = f\"{notify_url}/{request_id}/notes\"\n\n                # Perform our post\n                success, _ = self._fetch(method, url, payload, params)\n\n                if not success:\n                    has_error = True\n\n            if not has_error and action == OpsgenieAlertAction.DELETE:\n                # Remove cached entry\n                self.store.clear(key)\n\n        else:\n            self.logger.info(\n                \"No Opsgenie notification sent due to (nothing to %s) \"\n                \"condition\",\n                self.action,\n            )\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.region_name, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"action\": self.action,\n            \"region\": self.region_name,\n            \"priority\": (\n                OPSGENIE_PRIORITIES[self.template_args[\"priority\"][\"default\"]]\n                if self.priority not in OPSGENIE_PRIORITIES\n                else OPSGENIE_PRIORITIES[self.priority]\n            ),\n            \"batch\": \"yes\" if self.batch_size > 1 else \"no\",\n        }\n\n        # Assign our entity value (if defined)\n        if self.entity:\n            params[\"entity\"] = self.entity\n\n        # Assign our alias value (if defined)\n        if self.alias:\n            params[\"alias\"] = self.alias\n\n        # Assign our tags (if specifed)\n        if self.__tags:\n            params[\"tags\"] = \",\".join(self.__tags)\n\n        # Append our details into our parameters\n        params.update({f\"+{k}\": v for k, v in self.details.items()})\n\n        # Append our assignment extra's into our parameters\n        params.update({f\":{k.value}\": v for k, v in self.mapping.items()})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # A map allows us to map our target types so they can be correctly\n        # placed back into your URL below. Hence map the 'user' -> '@'\n        map_ = {\n            OpsgenieCategory.USER: NotifyOpsgenie.template_tokens[\n                \"target_user\"\n            ][\"prefix\"],\n            OpsgenieCategory.SCHEDULE: NotifyOpsgenie.template_tokens[\n                \"target_schedule\"\n            ][\"prefix\"],\n            OpsgenieCategory.ESCALATION: NotifyOpsgenie.template_tokens[\n                \"target_escalation\"\n            ][\"prefix\"],\n            OpsgenieCategory.TEAM: NotifyOpsgenie.template_tokens[\n                \"target_team\"\n            ][\"prefix\"],\n        }\n\n        return \"{schema}://{user}{apikey}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            user=f\"{self.user}@\" if self.user else \"\",\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join([\n                NotifyOpsgenie.quote(\n                    \"{}{}\".format(\n                        map_[x[\"type\"]],\n                        x.get(\"id\", x.get(\"name\", x.get(\"username\"))),\n                    )\n                )\n                for x in self.targets\n            ]),\n            params=NotifyOpsgenie.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        targets = len(self.targets)\n        if self.batch_size > 1:\n            targets = int(targets / self.batch_size) + (\n                1 if targets % self.batch_size else 0\n            )\n\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The API Key is stored in the hostname\n        results[\"apikey\"] = NotifyOpsgenie.unquote(results[\"host\"])\n\n        # Get our Targets\n        results[\"targets\"] = NotifyOpsgenie.split_path(results[\"fullpath\"])\n\n        # Add our Meta Detail keys\n        results[\"details\"] = {\n            NotifyBase.unquote(x): NotifyBase.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyOpsgenie.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        # Get Batch Boolean (if set)\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyOpsgenie.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            results[\"apikey\"] = NotifyOpsgenie.unquote(\n                results[\"qsd\"][\"apikey\"]\n            )\n\n        if \"tags\" in results[\"qsd\"] and len(results[\"qsd\"][\"tags\"]):\n            # Extract our tags\n            results[\"tags\"] = parse_list(\n                NotifyOpsgenie.unquote(results[\"qsd\"][\"tags\"])\n            )\n\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            # Extract our region\n            results[\"region_name\"] = NotifyOpsgenie.unquote(\n                results[\"qsd\"][\"region\"]\n            )\n\n        if \"entity\" in results[\"qsd\"] and len(results[\"qsd\"][\"entity\"]):\n            # Extract optional entity field\n            results[\"entity\"] = NotifyOpsgenie.unquote(\n                results[\"qsd\"][\"entity\"]\n            )\n\n        if \"alias\" in results[\"qsd\"] and len(results[\"qsd\"][\"alias\"]):\n            # Extract optional alias field\n            results[\"alias\"] = NotifyOpsgenie.unquote(results[\"qsd\"][\"alias\"])\n\n        # Handle 'to' email address\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(results[\"qsd\"][\"to\"])\n\n        # Store our action (if defined)\n        if \"action\" in results[\"qsd\"] and len(results[\"qsd\"][\"action\"]):\n            results[\"action\"] = NotifyOpsgenie.unquote(\n                results[\"qsd\"][\"action\"]\n            )\n\n        # store any custom mapping defined\n        results[\"mapping\"] = {\n            NotifyOpsgenie.unquote(x): NotifyOpsgenie.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pagerduty.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API Refererence:\n#   - https://developer.pagerduty.com/api-reference/\\\n#       368ae3d938c9e-send-an-event-to-pager-duty\n#\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, validate_regex\nfrom .base import NotifyBase\n\n\nclass PagerDutySeverity:\n    \"\"\"Defines the Pager Duty Severity Levels.\"\"\"\n\n    INFO = \"info\"\n\n    WARNING = \"warning\"\n\n    ERROR = \"error\"\n\n    CRITICAL = \"critical\"\n\n\n# Map all support Apprise Categories with the Pager Duty ones\nPAGERDUTY_SEVERITY_MAP = {\n    NotifyType.INFO: PagerDutySeverity.INFO,\n    NotifyType.SUCCESS: PagerDutySeverity.INFO,\n    NotifyType.WARNING: PagerDutySeverity.WARNING,\n    NotifyType.FAILURE: PagerDutySeverity.CRITICAL,\n}\n\nPAGERDUTY_SEVERITIES = (\n    PagerDutySeverity.INFO,\n    PagerDutySeverity.WARNING,\n    PagerDutySeverity.CRITICAL,\n    PagerDutySeverity.ERROR,\n)\n\n\n# Priorities\nclass PagerDutyRegion:\n    US = \"us\"\n    EU = \"eu\"\n\n\n# SparkPost APIs\nPAGERDUTY_API_LOOKUP = {\n    PagerDutyRegion.US: \"https://events.pagerduty.com/v2/enqueue\",\n    PagerDutyRegion.EU: \"https://events.eu.pagerduty.com/v2/enqueue\",\n}\n\n# A List of our regions we can use for verification\nPAGERDUTY_REGIONS = (\n    PagerDutyRegion.US,\n    PagerDutyRegion.EU,\n)\n\n\nclass NotifyPagerDuty(NotifyBase):\n    \"\"\"A wrapper for Pager Duty Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Pager Duty\"\n\n    # The services URL\n    service_url = \"https://pagerduty.com/\"\n\n    # Secure Protocol\n    secure_protocol = \"pagerduty\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pagerduty/\"\n\n    # We don't support titles for Pager Duty notifications\n    title_maxlen = 0\n\n    # Allows the user to specify the NotifyImageSize object; this is supported\n    # through the webhook\n    image_size = NotifyImageSize.XY_128\n\n    # Our event action type\n    event_action = \"trigger\"\n\n    # The default region to use if one isn't otherwise specified\n    default_region = PagerDutyRegion.US\n\n    # Define object templates\n    templates = (\n        \"{schema}://{integrationkey}@{apikey}\",\n        \"{schema}://{integrationkey}@{apikey}/{source}\",\n        \"{schema}://{integrationkey}@{apikey}/{source}/{component}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            # Optional but triggers V2 API\n            \"integrationkey\": {\n                \"name\": _(\"Integration Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"source\": {\n                # Optional Source Identifier (preferably a FQDN)\n                \"name\": _(\"Source\"),\n                \"type\": \"string\",\n                \"default\": \"Apprise\",\n            },\n            \"component\": {\n                # Optional Component Identifier\n                \"name\": _(\"Component\"),\n                \"type\": \"string\",\n                \"default\": \"Notification\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"group\": {\n                \"name\": _(\"Group\"),\n                \"type\": \"string\",\n            },\n            \"class\": {\n                \"name\": _(\"Class\"),\n                \"type\": \"string\",\n                \"map_to\": \"class_id\",\n            },\n            \"click\": {\n                \"name\": _(\"Click\"),\n                \"type\": \"string\",\n            },\n            \"region\": {\n                \"name\": _(\"Region Name\"),\n                \"type\": \"choice:string\",\n                \"values\": PAGERDUTY_REGIONS,\n                \"default\": PagerDutyRegion.US,\n                \"map_to\": \"region_name\",\n            },\n            # The severity is automatically determined, however you can\n            # optionally over-ride its value and force it to be what you want\n            \"severity\": {\n                \"name\": _(\"Severity\"),\n                \"type\": \"choice:string\",\n                \"values\": PAGERDUTY_SEVERITIES,\n                \"map_to\": \"severity\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"details\": {\n            \"name\": _(\"Custom Details\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self,\n        apikey,\n        integrationkey=None,\n        source=None,\n        component=None,\n        group=None,\n        class_id=None,\n        include_image=True,\n        click=None,\n        details=None,\n        region_name=None,\n        severity=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Pager Duty Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Long-Lived Access token (generated from User Profile)\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid Pager Duty API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.integration_key = validate_regex(integrationkey)\n        if not self.integration_key:\n            msg = (\n                \"An invalid Pager Duty Routing Key \"\n                f\"({integrationkey}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # An Optional Source\n        self.source = self.template_tokens[\"source\"][\"default\"]\n        if source:\n            self.source = validate_regex(source)\n            if not self.source:\n                msg = (\n                    \"An invalid Pager Duty Notification Source \"\n                    f\"({source}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.component = self.template_tokens[\"source\"][\"default\"]\n\n        # An Optional Component\n        self.component = self.template_tokens[\"component\"][\"default\"]\n        if component:\n            self.component = validate_regex(component)\n            if not self.component:\n                msg = (\n                    \"An invalid Pager Duty Notification Component \"\n                    f\"({component}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.component = self.template_tokens[\"component\"][\"default\"]\n\n        # Store our region\n        try:\n            self.region_name = (\n                self.default_region\n                if region_name is None\n                else region_name.lower()\n            )\n\n            if self.region_name not in PAGERDUTY_REGIONS:\n                # allow the outer except to handle this common response\n                raise IndexError()\n\n        except (AttributeError, IndexError, TypeError):\n            # Invalid region specified\n            msg = f\"The PagerDuty region specified ({region_name}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        # The severity (if specified)\n        self.severity = (\n            None\n            if severity is None\n            else next(\n                (\n                    s\n                    for s in PAGERDUTY_SEVERITIES\n                    if str(s).lower().startswith(severity)\n                ),\n                False,\n            )\n        )\n\n        if self.severity is False:\n            # Invalid severity specified\n            msg = f\"The PagerDuty severity specified ({severity}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # A clickthrough option for notifications\n        self.click = click\n\n        # Store Class ID if specified\n        self.class_id = class_id\n\n        # Store Group if specified\n        self.group = group\n\n        self.details = {}\n        if details:\n            # Store our extra details\n            self.details.update(details)\n\n        # Display our Apprise Image\n        self.include_image = include_image\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send our PagerDuty Notification.\"\"\"\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Token token={self.apikey}\",\n        }\n\n        # Prepare our persistent_notification.create payload\n        payload = {\n            # Define our integration key\n            \"routing_key\": self.integration_key,\n            # Prepare our payload\n            \"payload\": {\n                \"summary\": body,\n                # Set our severity\n                \"severity\": (\n                    PAGERDUTY_SEVERITY_MAP[notify_type]\n                    if not self.severity\n                    else self.severity\n                ),\n                # Our Alerting Source/Component\n                \"source\": self.source,\n                \"component\": self.component,\n            },\n            \"client\": self.app_id,\n            # Our Event Action\n            \"event_action\": self.event_action,\n        }\n\n        if self.group:\n            payload[\"payload\"][\"group\"] = self.group\n\n        if self.class_id:\n            payload[\"payload\"][\"class\"] = self.class_id\n\n        if self.click:\n            payload[\"links\"] = [{\n                \"href\": self.click,\n            }]\n\n        # Acquire our image url if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        if image_url:\n            payload[\"images\"] = [{\n                \"src\": image_url,\n                \"alt\": notify_type.value,\n            }]\n\n        if self.details:\n            payload[\"payload\"][\"custom_details\"] = {}\n            # Apply any provided custom details\n            for k, v in self.details.items():\n                payload[\"payload\"][\"custom_details\"][k] = v\n\n        # Prepare our URL based on region\n        notify_url = PAGERDUTY_API_LOOKUP[self.region_name]\n\n        self.logger.debug(\n            \"Pager Duty POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Pager Duty Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.created,\n                requests.codes.accepted,\n            ):\n                # We had a problem\n                status_str = NotifyPagerDuty.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Pager Duty notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Pager Duty notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Pager Duty \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.integration_key,\n            self.apikey,\n            self.source,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"region\": self.region_name,\n            \"image\": \"yes\" if self.include_image else \"no\",\n        }\n        if self.class_id:\n            params[\"class\"] = self.class_id\n\n        if self.group:\n            params[\"group\"] = self.group\n\n        if self.click is not None:\n            params[\"click\"] = self.click\n\n        if self.severity:\n            params[\"severity\"] = self.severity\n\n        # Append our custom entries our parameters\n        params.update({f\"+{k}\": v for k, v in self.details.items()})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        url = (\n            \"{schema}://{integration_key}@{apikey}/\"\n            \"{source}/{component}?{params}\"\n        )\n\n        return url.format(\n            schema=self.secure_protocol,\n            # never encode hostname since we're expecting it to be a valid one\n            integration_key=self.pprint(\n                self.integration_key, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            apikey=self.pprint(\n                self.apikey, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            source=self.pprint(self.source, privacy, safe=\"\"),\n            component=self.pprint(self.component, privacy, safe=\"\"),\n            params=NotifyPagerDuty.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The 'apikey' makes it easier to use yaml configuration\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            results[\"apikey\"] = NotifyPagerDuty.unquote(\n                results[\"qsd\"][\"apikey\"]\n            )\n        else:\n            results[\"apikey\"] = NotifyPagerDuty.unquote(results[\"host\"])\n\n        # The 'integrationkey' makes it easier to use yaml configuration\n        if \"integrationkey\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"integrationkey\"]\n        ):\n            results[\"integrationkey\"] = NotifyPagerDuty.unquote(\n                results[\"qsd\"][\"integrationkey\"]\n            )\n        else:\n            results[\"integrationkey\"] = NotifyPagerDuty.unquote(\n                results[\"user\"]\n            )\n\n        if \"click\" in results[\"qsd\"] and len(results[\"qsd\"][\"click\"]):\n            results[\"click\"] = NotifyPagerDuty.unquote(results[\"qsd\"][\"click\"])\n\n        if \"group\" in results[\"qsd\"] and len(results[\"qsd\"][\"group\"]):\n            results[\"group\"] = NotifyPagerDuty.unquote(results[\"qsd\"][\"group\"])\n\n        if \"class\" in results[\"qsd\"] and len(results[\"qsd\"][\"class\"]):\n            results[\"class_id\"] = NotifyPagerDuty.unquote(\n                results[\"qsd\"][\"class\"]\n            )\n\n        if \"severity\" in results[\"qsd\"] and len(results[\"qsd\"][\"severity\"]):\n            results[\"severity\"] = NotifyPagerDuty.unquote(\n                results[\"qsd\"][\"severity\"]\n            )\n\n        # Acquire our full path\n        fullpath = NotifyPagerDuty.split_path(results[\"fullpath\"])\n\n        # Get our source\n        if \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifyPagerDuty.unquote(\n                results[\"qsd\"][\"source\"]\n            )\n        else:\n            results[\"source\"] = fullpath.pop(0) if fullpath else None\n\n        # Get our component\n        if \"component\" in results[\"qsd\"] and len(results[\"qsd\"][\"component\"]):\n            results[\"component\"] = NotifyPagerDuty.unquote(\n                results[\"qsd\"][\"component\"]\n            )\n        else:\n            results[\"component\"] = fullpath.pop(0) if fullpath else None\n\n        # Add our custom details key/value pairs that the user can potentially\n        # over-ride if they wish to to our returned result set and tidy\n        # entries by unquoting them\n        results[\"details\"] = {\n            NotifyPagerDuty.unquote(x): NotifyPagerDuty.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            # Extract from name to associate with from address\n            results[\"region_name\"] = NotifyPagerDuty.unquote(\n                results[\"qsd\"][\"region\"]\n            )\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pagertree.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\nfrom uuid import uuid4\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n\n# Actions\nclass PagerTreeAction:\n    CREATE = \"create\"\n    ACKNOWLEDGE = \"acknowledge\"\n    RESOLVE = \"resolve\"\n\n\n# Urgencies\nclass PagerTreeUrgency:\n    SILENT = \"silent\"\n    LOW = \"low\"\n    MEDIUM = \"medium\"\n    HIGH = \"high\"\n    CRITICAL = \"critical\"\n\n\nPAGERTREE_ACTIONS = {\n    PagerTreeAction.CREATE: \"create\",\n    PagerTreeAction.ACKNOWLEDGE: \"acknowledge\",\n    PagerTreeAction.RESOLVE: \"resolve\",\n}\n\nPAGERTREE_URGENCIES = {\n    # Note: This also acts as a reverse lookup mapping\n    PagerTreeUrgency.SILENT: \"silent\",\n    PagerTreeUrgency.LOW: \"low\",\n    PagerTreeUrgency.MEDIUM: \"medium\",\n    PagerTreeUrgency.HIGH: \"high\",\n    PagerTreeUrgency.CRITICAL: \"critical\",\n}\n# Extend HTTP Error Messages\nPAGERTREE_HTTP_ERROR_MAP = {\n    402: \"Payment Required - Please subscribe or upgrade\",\n    403: \"Forbidden - Blocked\",\n    404: \"Not Found - Invalid Integration ID\",\n    405: \"Method Not Allowed - Integration Disabled\",\n    429: \"Too Many Requests - Rate Limit Exceeded\",\n}\n\n\nclass NotifyPagerTree(NotifyBase):\n    \"\"\"A wrapper for PagerTree Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"PagerTree\"\n\n    # The services URL\n    service_url = \"https://pagertree.com/\"\n\n    # All PagerTree requests are secure\n    secure_protocol = \"pagertree\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pagertree/\"\n\n    # PagerTree uses the http protocol with JSON requests\n    notify_url = \"https://api.pagertree.com/integration/{}\"\n\n    # Define object templates\n    templates = (\"{schema}://{integration}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"integration\": {\n                \"name\": _(\"Integration ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            }\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"action\": {\n                \"name\": _(\"Action\"),\n                \"type\": \"choice:string\",\n                \"values\": PAGERTREE_ACTIONS,\n                \"default\": PagerTreeAction.CREATE,\n            },\n            \"thirdparty\": {\n                \"name\": _(\"Third Party ID\"),\n                \"type\": \"string\",\n            },\n            \"urgency\": {\n                \"name\": _(\"Urgency\"),\n                \"type\": \"choice:string\",\n                \"values\": PAGERTREE_URGENCIES,\n            },\n            \"tags\": {\n                \"name\": _(\"Tags\"),\n                \"type\": \"string\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n        \"payload_extras\": {\n            \"name\": _(\"Payload Extras\"),\n            \"prefix\": \":\",\n        },\n        \"meta_extras\": {\n            \"name\": _(\"Meta Extras\"),\n            \"prefix\": \"-\",\n        },\n    }\n\n    def __init__(\n        self,\n        integration,\n        action=None,\n        thirdparty=None,\n        urgency=None,\n        tags=None,\n        headers=None,\n        payload_extras=None,\n        meta_extras=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize PagerTree Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Integration ID (associated with account)\n        self.integration = validate_regex(\n            integration, r\"^int_[a-zA-Z0-9\\-_]{7,14}$\"\n        )\n        if not self.integration:\n            msg = (\n                \"An invalid PagerTree Integration ID \"\n                f\"({integration}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # thirdparty (optional, in case they want to pass the\n        # acknowledge or resolve action)\n        self.thirdparty = None\n        if thirdparty:\n            # An id was specified, we want to validate it\n            self.thirdparty = validate_regex(thirdparty)\n            if not self.thirdparty:\n                msg = (\n                    \"An invalid PagerTree third party ID \"\n                    f\"({thirdparty}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        self.payload_extras = {}\n        if payload_extras:\n            # Store our extra payload entries\n            self.payload_extras.update(payload_extras)\n\n        self.meta_extras = {}\n        if meta_extras:\n            # Store our extra payload entries\n            self.meta_extras.update(meta_extras)\n\n        # Setup our action\n        self.action = (\n            NotifyPagerTree.template_args[\"action\"][\"default\"]\n            if action not in PAGERTREE_ACTIONS\n            else PAGERTREE_ACTIONS[action]\n        )\n\n        # Setup our urgency\n        self.urgency = PAGERTREE_URGENCIES.get(urgency)\n\n        # Any optional tags to attach to the notification\n        self.__tags = parse_list(tags)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform PagerTree Notification.\"\"\"\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Apply any/all header over-rides defined\n        # For things like PagerTree Token\n        headers.update(self.headers)\n\n        # prepare JSON Object\n        payload = {\n            # Generate an ID (unless one was explicitly forced to be used)\n            \"id\": self.thirdparty if self.thirdparty else str(uuid4()),\n            \"event_type\": self.action,\n        }\n\n        if self.action == PagerTreeAction.CREATE:\n            payload[\"title\"] = title if title else self.app_desc\n            payload[\"description\"] = body\n\n            payload[\"meta\"] = self.meta_extras\n            payload[\"tags\"] = self.__tags\n\n            if self.urgency is not None:\n                payload[\"urgency\"] = self.urgency\n\n        # Apply any/all payload over-rides defined\n        payload.update(self.payload_extras)\n\n        # Prepare our URL based on integration\n        notify_url = self.notify_url.format(self.integration)\n\n        self.logger.debug(\n            \"PagerTree POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"PagerTree Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.created,\n                requests.codes.accepted,\n            ):\n                # We had a problem\n                status_str = NotifyPagerTree.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send PagerTree notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent PagerTree notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending PagerTree \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.integration)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"action\": self.action,\n        }\n\n        if self.thirdparty:\n            params[\"tid\"] = self.thirdparty\n\n        if self.urgency:\n            params[\"urgency\"] = self.urgency\n\n        if self.__tags:\n            params[\"tags\"] = \",\".join(list(self.__tags))\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Headers prefixed with a '+' sign\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Meta: {} prefixed with a '-' sign\n        # Append our meta extras into our parameters\n        params.update({f\"-{k}\": v for k, v in self.meta_extras.items()})\n\n        # Payload body extras prefixed with a ':' sign\n        # Append our payload extras into our parameters\n        params.update({f\":{k}\": v for k, v in self.payload_extras.items()})\n\n        return \"{schema}://{integration}?{params}\".format(\n            schema=self.secure_protocol,\n            # never encode hostname since we're expecting it to be a valid one\n            integration=self.pprint(self.integration, privacy, safe=\"\"),\n            params=NotifyPagerTree.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # store any additional payload extra's defined\n        results[\"payload_extras\"] = {\n            NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        # store any additional meta extra's defined\n        results[\"meta_extras\"] = {\n            NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y)\n            for x, y in results[\"qsd-\"].items()\n        }\n\n        # Integration ID\n        if \"id\" in results[\"qsd\"] and len(results[\"qsd\"][\"id\"]):\n            # Shortened version of integration id\n            results[\"integration\"] = NotifyPagerTree.unquote(\n                results[\"qsd\"][\"id\"]\n            )\n\n        elif \"integration\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"integration\"]\n        ):\n            results[\"integration\"] = NotifyPagerTree.unquote(\n                results[\"qsd\"][\"integration\"]\n            )\n\n        else:\n            results[\"integration\"] = NotifyPagerTree.unquote(results[\"host\"])\n\n        # Set our thirdparty\n\n        if \"tid\" in results[\"qsd\"] and len(results[\"qsd\"][\"tid\"]):\n            # Shortened version of thirdparty\n            results[\"thirdparty\"] = NotifyPagerTree.unquote(\n                results[\"qsd\"][\"tid\"]\n            )\n\n        elif \"thirdparty\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"thirdparty\"]\n        ):\n            results[\"thirdparty\"] = NotifyPagerTree.unquote(\n                results[\"qsd\"][\"thirdparty\"]\n            )\n\n        # Set our urgency\n        if \"action\" in results[\"qsd\"] and len(results[\"qsd\"][\"action\"]):\n            results[\"action\"] = NotifyPagerTree.unquote(\n                results[\"qsd\"][\"action\"]\n            )\n\n        # Set our urgency\n        if \"urgency\" in results[\"qsd\"] and len(results[\"qsd\"][\"urgency\"]):\n            results[\"urgency\"] = NotifyPagerTree.unquote(\n                results[\"qsd\"][\"urgency\"]\n            )\n\n        # Set our tags\n        if \"tags\" in results[\"qsd\"] and len(results[\"qsd\"][\"tags\"]):\n            results[\"tags\"] = parse_list(\n                NotifyPagerTree.unquote(results[\"qsd\"][\"tags\"])\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/parseplatform.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n# Used to break path apart into list of targets\nTARGET_LIST_DELIM = re.compile(r\"[ \\t\\r\\n,\\\\/]+\")\n\n\n# Priorities\nclass ParsePlatformDevice:\n    # All Devices\n    ALL = \"all\"\n\n    # Apple IOS (APNS)\n    IOS = \"ios\"\n\n    # Android/Firebase (FCM)\n    ANDROID = \"android\"\n\n\nPARSE_PLATFORM_DEVICES = (\n    ParsePlatformDevice.ALL,\n    ParsePlatformDevice.IOS,\n    ParsePlatformDevice.ANDROID,\n)\n\n\nclass NotifyParsePlatform(NotifyBase):\n    \"\"\"A wrapper for Parse Platform Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Parse Platform\"\n\n    # The services URL\n    service_url = \" https://parseplatform.org/\"\n\n    # insecure notifications (using http)\n    protocol = \"parsep\"\n\n    # Secure notifications (using https)\n    secure_protocol = \"parseps\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/parseplatform/\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{app_id}:{master_key}@{host}\",\n        \"{schema}://{app_id}:{master_key}@{host}:{port}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"app_id\": {\n                \"name\": _(\"App ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"master_key\": {\n                \"name\": _(\"Master Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"device\": {\n                \"name\": _(\"Device\"),\n                \"type\": \"choice:string\",\n                \"values\": PARSE_PLATFORM_DEVICES,\n                \"default\": ParsePlatformDevice.ALL,\n            },\n            \"app_id\": {\n                \"alias_of\": \"app_id\",\n            },\n            \"master_key\": {\n                \"alias_of\": \"master_key\",\n            },\n        },\n    )\n\n    def __init__(self, app_id, master_key, device=None, **kwargs):\n        \"\"\"Initialize Parse Platform Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.fullpath = kwargs.get(\"fullpath\")\n        if not isinstance(self.fullpath, str):\n            self.fullpath = \"/\"\n\n        # Application ID\n        self.application_id = validate_regex(app_id)\n        if not self.application_id:\n            msg = (\n                \"An invalid Parse Platform Application ID \"\n                f\"({app_id}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Master Key\n        self.master_key = validate_regex(master_key)\n        if not self.master_key:\n            msg = (\n                \"An invalid Parse Platform Master Key \"\n                f\"({master_key}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Initialize Devices Array\n        self.devices = []\n\n        if device:\n            self.device = device.lower()\n            if device not in PARSE_PLATFORM_DEVICES:\n                msg = (\n                    \"An invalid Parse Platform device \"\n                    f\"({device}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.device = self.template_args[\"device\"][\"default\"]\n\n        if self.device == ParsePlatformDevice.ALL:\n            self.devices = [\n                d\n                for d in PARSE_PLATFORM_DEVICES\n                if d != ParsePlatformDevice.ALL\n            ]\n        else:\n            # Store our device\n            self.devices.append(device)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Parse Platform Notification.\"\"\"\n\n        # Prepare our headers:\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"X-Parse-Application-Id\": self.application_id,\n            \"X-Parse-Master-Key\": self.master_key,\n        }\n\n        # prepare our payload\n        payload = {\n            \"where\": {\n                \"deviceType\": {\n                    \"$in\": self.devices,\n                }\n            },\n            \"data\": {\n                \"title\": title,\n                \"alert\": body,\n            },\n        }\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        # Our Notification URL\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        url += self.fullpath.rstrip(\"/\") + \"/parse/push/\"\n\n        self.logger.debug(\n            \"Parse Platform POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Parse Platform Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyParsePlatform.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Parse Platform notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Parse Platform notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occured sending Parse Platform \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.application_id,\n            self.master_key,\n            self.host,\n            self.port,\n            self.fullpath,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any arguments set\n        params = {\n            \"device\": self.device,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        default_port = 443 if self.secure else 80\n\n        return (\n            \"{schema}://{app_id}:{master_key}@\"\n            \"{hostname}{port}{fullpath}/?{params}\".format(\n                schema=self.secure_protocol if self.secure else self.protocol,\n                app_id=self.pprint(self.application_id, privacy, safe=\"\"),\n                master_key=self.pprint(self.master_key, privacy, safe=\"\"),\n                hostname=NotifyParsePlatform.quote(self.host, safe=\"\"),\n                port=(\n                    \"\"\n                    if self.port is None or self.port == default_port\n                    else f\":{self.port}\"\n                ),\n                fullpath=NotifyParsePlatform.quote(self.fullpath, safe=\"/\"),\n                params=NotifyParsePlatform.urlencode(params),\n            )\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to\n        substantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # App ID is retrieved from the user\n        results[\"app_id\"] = NotifyParsePlatform.unquote(results[\"user\"])\n\n        # Master Key is retrieved from the password\n        results[\"master_key\"] = NotifyParsePlatform.unquote(\n            results[\"password\"]\n        )\n\n        # Device support override\n        if \"device\" in results[\"qsd\"] and len(results[\"qsd\"][\"device\"]):\n            results[\"device\"] = results[\"qsd\"][\"device\"]\n\n        # Allow app_id attribute over-ride\n        if \"app_id\" in results[\"qsd\"] and len(results[\"qsd\"][\"app_id\"]):\n            results[\"app_id\"] = results[\"qsd\"][\"app_id\"]\n\n        # Allow master_key attribute over-ride\n        if \"master_key\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"master_key\"]\n        ):\n            results[\"master_key\"] = results[\"qsd\"][\"master_key\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/plivo.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Create an account https://messagebird.com if you don't already have one\n#\n# Get your auth_id and auth token from the dashboard here:\n#   - https://console.plivo.com/dashboard/\n#\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_bool,\n    parse_phone_no,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n\nclass NotifyPlivo(NotifyBase):\n    \"\"\"A wrapper for Plivo Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Plivo\"\n\n    # The services URL\n    service_url = \"https://plivo.com\"\n\n    # The default protocol\n    secure_protocol = \"plivo\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/plivo/\"\n\n    # Plivo uses the http protocol with JSON requests\n    notify_url = \"https://api.plivo.com/v1/Account/{auth_id}/Message/\"\n\n    # The maximum number of messages that can be sent in a single batch\n    default_batch_size = 20\n\n    # The maximum length of the body\n    body_maxlen = 140\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{auth_id}@{token}/{source}\",\n        \"{schema}://{auth_id}@{token}/{source}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"auth_id\": {\n                \"name\": _(\"Auth ID\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]{20,30}$\", \"i\"),\n            },\n            \"token\": {\n                \"name\": _(\"Auth Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]{30,50}$\", \"i\"),\n            },\n            \"source\": {\n                \"name\": _(\"Source Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"required\": True,\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"source\",\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"id\": {\n                \"alias_of\": \"auth_id\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self, auth_id, token, source, targets=None, batch=None, **kwargs\n    ):\n        \"\"\"Initialize Plivo Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.auth_id = validate_regex(\n            auth_id, *self.template_tokens[\"auth_id\"][\"regex\"]\n        )\n        if not self.auth_id:\n            msg = (\n                f\"The Plivo authentication ID specified ({auth_id}) is \"\n                \"invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = (\n                f\"The Plivo authentication token specified ({token}) is \"\n                \"invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_phone_no(source)\n        if not result:\n            msg = f\"The Plivo source specified ({source}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our source; enforce E.164 format\n        self.source = f'+{result[\"full\"]}'\n\n        # Parse our targets\n        self.targets = []\n\n        if targets:\n            for target in parse_phone_no(targets):\n                # Validate targets and drop bad ones:\n                result = is_phone_no(target)\n                if result:\n                    # store valid phone number; enforce E.164 format\n                    self.targets.append(f'+{result[\"full\"]}')\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n        else:\n            # No sources specified, use our own phone no\n            self.targets.append(self.source)\n\n        # Set batch\n        self.batch = (\n            batch\n            if batch is not None\n            else self.template_args[\"batch\"][\"default\"]\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Plivo Notification.\"\"\"\n\n        if not self.targets:\n            # There were no services to notify\n            self.logger.warning(\"There were no Plivo targets to notify.\")\n            return False\n\n        # Initialize our has_error flag\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our authentication\n        auth = (self.auth_id, self.token)\n\n        # Prepare our payload\n        payload = {\n            \"src\": self.source,\n            \"dst\": None,\n            \"text\": body,\n        }\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        for index in range(0, len(self.targets), batch_size):\n            # Prepare our phone no (< delimits more then one)\n            payload[\"recipients\"] = \",\".join(\n                self.targets[index : index + batch_size]\n            )\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Plivo POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Plivo Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    auth=auth,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.accepted,\n                ):\n                    # We had a problem\n                    status_str = NotifyPlivo.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send {} Plivo notification{}: \"\n                        \"{}{}error={}.\".format(\n                            len(self.targets[index : index + batch_size]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if batch_size == 1\n                                else \"(s)\"\n                            ),\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        \"Send {} Plivo notification{}\".format(\n                            len(self.targets[index : index + batch_size]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if batch_size == 1\n                                else \"(s)\"\n                            ),\n                        )\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occured sending Plivo:{self.targets} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.auth_id,\n            self.token,\n            self.source,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any arguments set\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return (\n            \"{schema}://{auth_id}@{token}/{source}/{targets}/?{params}\".format(\n                schema=self.secure_protocol,\n                auth_id=self.pprint(self.auth_id, privacy, safe=\"\"),\n                token=self.pprint(self.token, privacy, safe=\"\"),\n                source=self.source,\n                targets=\"/\".join(\n                    [NotifyPlivo.quote(x, safe=\"+\") for x in self.targets]\n                ),\n                params=NotifyPlivo.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        return len(self.targets) if self.targets else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to\n        substantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The Auth ID is in the username field\n        if \"id\" in results[\"qsd\"] and len(results[\"qsd\"][\"id\"]):\n            results[\"auth_id\"] = NotifyPlivo.unquote(results[\"qsd\"][\"id\"])\n\n        else:\n            results[\"auth_id\"] = NotifyPlivo.unquote(results[\"user\"])\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyPlivo.split_path(results[\"fullpath\"])\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Store token\n            results[\"token\"] = NotifyPlivo.unquote(results[\"qsd\"][\"token\"])\n\n            # go ahead and put the host entry in the targets list\n            if results[\"host\"]:\n                results[\"targets\"].insert(\n                    0, NotifyPlivo.unquote(results[\"host\"])\n                )\n\n        else:\n            # The hostname is our authentication key\n            results[\"token\"] = NotifyPlivo.unquote(results[\"host\"])\n\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyPlivo.unquote(results[\"qsd\"][\"from\"])\n\n        else:\n            try:\n                # The first path entry is the source/originator\n                results[\"source\"] = results[\"targets\"].pop(0)\n\n            except IndexError:\n                # No source specified...\n                results[\"source\"] = None\n                pass\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyPlivo.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyPlivo.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/popcorn_notify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_email,\n    is_phone_no,\n    parse_bool,\n    parse_list,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n\nclass NotifyPopcornNotify(NotifyBase):\n    \"\"\"A wrapper for PopcornNotify Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"PopcornNotify\"\n\n    # The services URL\n    service_url = \"https://popcornnotify.com/\"\n\n    # The default protocol\n    secure_protocol = \"popcorn\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/popcornnotify/\"\n\n    # PopcornNotify uses the http protocol\n    notify_url = \"https://popcornnotify.com/notify\"\n\n    # The maximum targets to include when doing batch transfers\n    default_batch_size = 10\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n                \"required\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(self, apikey, targets=None, batch=False, **kwargs):\n        \"\"\"Initialize PopcornNotify Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Access Token (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid PopcornNotify API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_list(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if result:\n                # store valid phone number\n                self.targets.append(result[\"full\"])\n                continue\n\n            result = is_email(target)\n            if result:\n                # store valid email\n                self.targets.append(result[\"full_email\"])\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid target ({target}) specified.\",\n            )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform PopcornNotify Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\n                \"There were no PopcornNotify targets to notify.\"\n            )\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"message\": body,\n            \"subject\": title,\n        }\n\n        auth = (self.apikey, None)\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        for index in range(0, len(self.targets), batch_size):\n            # Prepare our recipients\n            payload[\"recipients\"] = \",\".join(\n                self.targets[index : index + batch_size]\n            )\n\n            self.logger.debug(\n                \"PopcornNotify POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"PopcornNotify Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    auth=auth,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyPopcornNotify.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send {} PopcornNotify notification{}: \"\n                        \"{}{}error={}.\".format(\n                            len(self.targets[index : index + batch_size]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if batch_size == 1\n                                else \"(s)\"\n                            ),\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        \"Sent {} PopcornNotify notification{}.\".format(\n                            len(self.targets[index : index + batch_size]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if batch_size == 1\n                                else \"(s)\"\n                            ),\n                        )\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occured sending\"\n                    f\" {len(self.targets[index:index + batch_size])} \"\n                    \"PopcornNotify notification(s).\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{apikey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyPopcornNotify.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyPopcornNotify.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyPopcornNotify.split_path(\n            results[\"fullpath\"]\n        )\n\n        # The hostname is our authentication key\n        results[\"apikey\"] = NotifyPopcornNotify.unquote(results[\"host\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyPopcornNotify.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(results[\"qsd\"].get(\"batch\", False))\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/prowl.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\n# Priorities\nclass ProwlPriority:\n    LOW = -2\n    MODERATE = -1\n    NORMAL = 0\n    HIGH = 1\n    EMERGENCY = 2\n\n\nPROWL_PRIORITIES = {\n    # Note: This also acts as a reverse lookup mapping\n    ProwlPriority.LOW: \"low\",\n    ProwlPriority.MODERATE: \"moderate\",\n    ProwlPriority.NORMAL: \"normal\",\n    ProwlPriority.HIGH: \"high\",\n    ProwlPriority.EMERGENCY: \"emergency\",\n}\n\nPROWL_PRIORITY_MAP = {\n    # Maps against string 'low'\n    \"l\": ProwlPriority.LOW,\n    # Maps against string 'moderate'\n    \"m\": ProwlPriority.MODERATE,\n    # Maps against string 'normal'\n    \"n\": ProwlPriority.NORMAL,\n    # Maps against string 'high'\n    \"h\": ProwlPriority.HIGH,\n    # Maps against string 'emergency'\n    \"e\": ProwlPriority.EMERGENCY,\n    # Entries to additionally support (so more like Prowl's API)\n    \"-2\": ProwlPriority.LOW,\n    \"-1\": ProwlPriority.MODERATE,\n    \"0\": ProwlPriority.NORMAL,\n    \"1\": ProwlPriority.HIGH,\n    \"2\": ProwlPriority.EMERGENCY,\n}\n\n# Provide some known codes Prowl uses and what they translate to:\nPROWL_HTTP_ERROR_MAP = {\n    406: \"IP address has exceeded API limit\",\n    409: \"Request not aproved.\",\n}\n\n\nclass NotifyProwl(NotifyBase):\n    \"\"\"A wrapper for Prowl Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Prowl\"\n\n    # The services URL\n    service_url = \"https://www.prowlapp.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"prowl\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/prowl/\"\n\n    # Prowl uses the http protocol with JSON requests\n    notify_url = \"https://api.prowlapp.com/publicapi/add\"\n\n    # Disable throttle rate for Prowl requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 10000\n\n    # Defines the maximum allowable characters in the title\n    title_maxlen = 1024\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}\",\n        \"{schema}://{apikey}/{providerkey}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Za-z0-9]{40}$\", \"i\"),\n            },\n            \"providerkey\": {\n                \"name\": _(\"Provider Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"regex\": (r\"^[A-Za-z0-9]{40}$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": PROWL_PRIORITIES,\n                \"default\": ProwlPriority.NORMAL,\n            },\n        },\n    )\n\n    def __init__(self, apikey, providerkey=None, priority=None, **kwargs):\n        \"\"\"Initialize Prowl Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # The Priority of the message\n        self.priority = (\n            NotifyProwl.template_args[\"priority\"][\"default\"]\n            if not priority\n            else next(\n                (\n                    v\n                    for k, v in PROWL_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyProwl.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Prowl API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store the provider key (if specified)\n        if providerkey:\n            self.providerkey = validate_regex(\n                providerkey, *self.template_tokens[\"providerkey\"][\"regex\"]\n            )\n            if not self.providerkey:\n                msg = (\n                    \"An invalid Prowl Provider Key \"\n                    f\"({providerkey}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        else:\n            # No provider key was set\n            self.providerkey = None\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Prowl Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # prepare JSON Object\n        payload = {\n            \"apikey\": self.apikey,\n            \"application\": self.app_id,\n            \"event\": title,\n            \"description\": body,\n            \"priority\": self.priority,\n        }\n\n        if self.providerkey:\n            payload[\"providerkey\"] = self.providerkey\n\n        self.logger.debug(\n            \"Prowl POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Prowl Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code, PROWL_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send Prowl notification:{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Prowl notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Prowl notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey, self.providerkey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"priority\": (\n                PROWL_PRIORITIES[self.template_args[\"priority\"][\"default\"]]\n                if self.priority not in PROWL_PRIORITIES\n                else PROWL_PRIORITIES[self.priority]\n            ),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{apikey}/{providerkey}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            providerkey=self.pprint(self.providerkey, privacy, safe=\"\"),\n            params=NotifyProwl.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Set the API Key\n        results[\"apikey\"] = NotifyProwl.unquote(results[\"host\"])\n\n        # Optionally try to find the provider key\n        with contextlib.suppress(IndexError):\n            results[\"providerkey\"] = NotifyProwl.split_path(\n                results[\"fullpath\"]\n            )[0]\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyProwl.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushbullet.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Flag used as a placeholder to sending to all devices\nPUSHBULLET_SEND_TO_ALL = \"ALL_DEVICES\"\n\n# Provide some known codes Pushbullet uses and what they translate to:\nPUSHBULLET_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n\nclass NotifyPushBullet(NotifyBase):\n    \"\"\"A wrapper for PushBullet Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Pushbullet\"\n\n    # The services URL\n    service_url = \"https://www.pushbullet.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"pbul\"\n\n    # Allow 50 requests per minute (Tier 2).\n    # 60/50 = 0.2\n    request_rate_per_sec = 1.2\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushbullet/\"\n\n    # PushBullet uses the http protocol with JSON requests\n    notify_url = \"https://api.pushbullet.com/v2/{}\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Define object templates\n    templates = (\n        \"{schema}://{accesstoken}\",\n        \"{schema}://{accesstoken}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"accesstoken\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_device\": {\n                \"name\": _(\"Target Device\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(self, accesstoken, targets=None, **kwargs):\n        \"\"\"Initialize PushBullet Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Access Token (associated with project)\n        self.accesstoken = validate_regex(accesstoken)\n        if not self.accesstoken:\n            msg = (\n                \"An invalid PushBullet Access Token \"\n                f\"({accesstoken}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.targets = parse_list(targets)\n        if len(self.targets) == 0:\n            self.targets = (PUSHBULLET_SEND_TO_ALL,)\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform PushBullet Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Build a list of our attachments\n        attachments = []\n\n        if attach and self.attachment_support:\n            # We need to upload our payload first so that we can source it\n            # in remaining messages\n            for no, attachment in enumerate(attach, start=1):\n\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Preparing PushBullet attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n                # prepare payload\n                payload = {\n                    \"file_name\": (\n                        attachment.name\n                        if attachment.name\n                        else f\"file{no:03}.dat\"\n                    ),\n                    \"file_type\": attachment.mimetype,\n                }\n                # First thing we need to do is make a request so that we can\n                # get a URL to post our request to.\n                # see: https://docs.pushbullet.com/#upload-request\n                okay, response = self._send(\n                    self.notify_url.format(\"upload-request\"), payload\n                )\n                if not okay:\n                    # We can't post our attachment\n                    return False\n\n                # If we get here, our output will look something like this:\n                # {\n                #   \"file_name\": \"cat.jpg\",\n                #   \"file_type\": \"image/jpeg\",\n                #   \"file_url\": \"https://dl.pushb.com/abc/cat.jpg\",\n                #   \"upload_url\": \"https://upload.pushbullet.com/abcd123\"\n                # }\n\n                # - The file_url is where the file will be available after it\n                #    is uploaded.\n                # - The upload_url is where to POST the file to. The file must\n                #    be posted using multipart/form-data encoding.\n\n                # Prepare our attachment payload; we'll use this if we\n                # successfully upload the content below for later on.\n                try:\n                    # By placing this in a try/except block we can validate\n                    # our response at the same time as preparing our payload\n                    payload = {\n                        # PushBullet v2/pushes file type:\n                        \"type\": \"file\",\n                        \"file_name\": response[\"file_name\"],\n                        \"file_type\": response[\"file_type\"],\n                        \"file_url\": response[\"file_url\"],\n                    }\n\n                    if response[\"file_type\"].startswith(\"image/\"):\n                        # Allow image to be displayed inline (if image type)\n                        payload[\"image_url\"] = response[\"file_url\"]\n\n                    upload_url = response[\"upload_url\"]\n\n                except (KeyError, TypeError):\n                    # A method of verifying our content exists\n                    return False\n\n                okay, response = self._send(upload_url, attachment)\n                if not okay:\n                    # We can't post our attachment\n                    return False\n\n                # Save our pre-prepared payload for attachment posting\n                attachments.append(payload)\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n        while len(targets):\n            recipient = targets.pop(0)\n\n            # prepare payload\n            payload = {\n                \"type\": \"note\",\n                \"title\": title,\n                \"body\": body,\n            }\n\n            # Check if an email was defined\n            match = is_email(recipient)\n            if match:\n                payload[\"email\"] = match[\"full_email\"]\n                self.logger.debug(\n                    f\"PushBullet recipient {recipient} parsed as an email\"\n                    \" address\"\n                )\n\n            elif recipient is PUSHBULLET_SEND_TO_ALL:\n                # Send to all\n                pass\n\n            elif recipient[0] == \"#\":\n                payload[\"channel_tag\"] = recipient[1:]\n                self.logger.debug(\n                    f\"PushBullet recipient {recipient} parsed as a channel\"\n                )\n\n            else:\n                payload[\"device_iden\"] = recipient\n                self.logger.debug(\n                    f\"PushBullet recipient {recipient} parsed as a device\"\n                )\n\n            if body:\n                okay, response = self._send(\n                    self.notify_url.format(\"pushes\"), payload\n                )\n                if not okay:\n                    has_error = True\n                    continue\n\n                self.logger.info(\n                    f'Sent PushBullet notification to \"{recipient}\".'\n                )\n\n            for attach_payload in attachments:\n                # Send our attachments to our same user (already prepared as\n                # our payload object)\n                okay, response = self._send(\n                    self.notify_url.format(\"pushes\"), attach_payload\n                )\n                if not okay:\n                    has_error = True\n                    continue\n\n                self.logger.info(\n                    'Sent PushBullet attachment ({}) to \"{}\".'.format(\n                        attach_payload[\"file_name\"], recipient\n                    )\n                )\n\n        return not has_error\n\n    def _send(self, url, payload, **kwargs):\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Some default values for our request object to which we'll update\n        # depending on what our payload is\n        files = None\n        data = None\n\n        if not isinstance(payload, AttachBase):\n            # Send our payload as a JSON object\n            headers[\"Content-Type\"] = \"application/json\"\n            data = dumps(payload) if payload else None\n\n        auth = (self.accesstoken, \"\")\n\n        self.logger.debug(\n            \"PushBullet POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"PushBullet Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # Default response type\n        response = None\n\n        try:\n            # Open our attachment path if required:\n            if isinstance(payload, AttachBase):\n                files = {\n                    \"file\": (\n                        payload.name,\n                        # file handle is safely closed in `finally`; inline\n                        # open is intentional\n                        open(payload.path, \"rb\"),  # noqa: SIM115\n                    )}\n\n            r = requests.post(\n                url,\n                data=data,\n                headers=headers,\n                files=files,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                response = loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n\n                # Fall back to the existing unparsed value\n                response = r.content\n\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n                # We had a problem\n                status_str = NotifyPushBullet.http_response_code_lookup(\n                    r.status_code, PUSHBULLET_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to deliver payload to PushBullet:\"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False, response\n\n            # otherwise we were successful\n            return True, response\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred communicating with PushBullet.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False, response\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while handling {}.\".format(\n                    payload.name\n                    if isinstance(payload, AttachBase)\n                    else payload\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return False, response\n\n        finally:\n            # Close our file (if it's open) stored in the second element\n            # of our files tuple (index 1)\n            if files:\n                files[\"file\"][1].close()\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.accesstoken)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        targets = \"/\".join([NotifyPushBullet.quote(x) for x in self.targets])\n        if targets == PUSHBULLET_SEND_TO_ALL:\n            # keyword is reserved for internal usage only; it's safe to remove\n            # it from the recipients list\n            targets = \"\"\n\n        return \"{schema}://{accesstoken}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            accesstoken=self.pprint(self.accesstoken, privacy, safe=\"\"),\n            targets=targets,\n            params=NotifyPushBullet.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Fetch our targets\n        results[\"targets\"] = NotifyPushBullet.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyPushBullet.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Setup the token; we store it in Access Token for global\n        # plugin consistency with naming conventions\n        results[\"accesstoken\"] = NotifyPushBullet.unquote(results[\"host\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushdeer.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n# Syntax:\n#  schan://{key}/\n\n\nclass NotifyPushDeer(NotifyBase):\n    \"\"\"A wrapper for PushDeer Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"PushDeer\"\n\n    # The services URL\n    service_url = \"https://www.pushdeer.com/\"\n\n    # Insecure Protocol Access\n    protocol = \"pushdeer\"\n\n    # Secure Protocol\n    secure_protocol = \"pushdeers\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushdeer/\"\n\n    # Default hostname\n    default_hostname = \"api2.pushdeer.com\"\n\n    # PushDeer API\n    notify_url = \"{schema}://{host}:{port}/message/push?pushkey={pushKey}\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{pushkey}\",\n        \"{schema}://{host}/{pushkey}\",\n        \"{schema}://{host}:{port}/{pushkey}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"pushkey\": {\n                \"name\": _(\"Pushkey\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, pushkey, **kwargs):\n        \"\"\"Initialize PushDeer Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # PushKey (associated with project)\n        self.push_key = validate_regex(\n            pushkey, *self.template_tokens[\"pushkey\"][\"regex\"]\n        )\n        if not self.push_key:\n            msg = f\"An invalid PushDeer API Pushkey ({pushkey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform PushDeer Notification.\"\"\"\n\n        # Prepare our persistent_notification.create payload\n        payload = {\n            \"text\": title if title else body,\n            \"type\": \"text\",\n            \"desp\": body if title else \"\",\n        }\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        # Set host\n        host = self.default_hostname\n        if self.host:\n            host = self.host\n\n        # Set port\n        port = 443 if self.secure else 80\n        if self.port:\n            port = self.port\n\n        # Our Notification URL\n        notify_url = self.notify_url.format(\n            schema=schema, host=host, port=port, pushKey=self.push_key\n        )\n\n        # Some Debug Logging\n        self.logger.debug(\n            \"PushDeer URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"PushDeer Payload: {payload}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=payload,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyPushDeer.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send PushDeer notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False\n\n            else:\n                self.logger.info(\"Sent PushDeer notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occured sending PushDeer notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.push_key,\n            self.host,\n            self.port,\n        )\n\n    def url(self, privacy=False):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        if self.host:\n            url = \"{schema}://{host}{port}/{pushkey}\"\n        else:\n            url = \"{schema}://{pushkey}\"\n\n        return url.format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            host=self.host,\n            port=\"\" if not self.port else f\":{self.port}\",\n            pushkey=self.pprint(self.push_key, privacy, safe=\"\"),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to\n        substantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't parse the URL\n            return results\n\n        fullpaths = NotifyPushDeer.split_path(results[\"fullpath\"])\n\n        if len(fullpaths) == 0:\n            results[\"pushkey\"] = results[\"host\"]\n            results[\"host\"] = None\n        else:\n            results[\"pushkey\"] = fullpaths.pop()\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushed.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom itertools import chain\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Used to detect and parse channels\nIS_CHANNEL = re.compile(r\"^#?(?P<name>[A-Za-z0-9]+)$\")\n\n# Used to detect and parse a users push id\nIS_USER_PUSHED_ID = re.compile(r\"^@(?P<name>[A-Za-z0-9]+)$\")\n\n\nclass NotifyPushed(NotifyBase):\n    \"\"\"A wrapper to Pushed Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Pushed\"\n\n    # The services URL\n    service_url = \"https://pushed.co/\"\n\n    # The default secure protocol\n    secure_protocol = \"pushed\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushed/\"\n\n    # Pushed uses the http protocol with JSON requests\n    notify_url = \"https://api.pushed.co/1/push\"\n\n    # A title can not be used for Pushed Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 160\n\n    # Define object templates\n    templates = (\n        \"{schema}://{app_key}/{app_secret}\",\n        \"{schema}://{app_key}/{app_secret}@{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"app_key\": {\n                \"name\": _(\"Application Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"app_secret\": {\n                \"name\": _(\"Application Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"prefix\": \"@\",\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(self, app_key, app_secret, targets=None, **kwargs):\n        \"\"\"Initialize Pushed Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Application Key (associated with project)\n        self.app_key = validate_regex(app_key)\n        if not self.app_key:\n            msg = (\n                f\"An invalid Pushed Application Key ({app_key}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Access Secret (associated with project)\n        self.app_secret = validate_regex(app_secret)\n        if not self.app_secret:\n            msg = (\n                \"An invalid Pushed Application Secret \"\n                f\"({app_secret}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Initialize channel list\n        self.channels = []\n\n        # Initialize user list\n        self.users = []\n\n        # Get our targets\n        targets = parse_list(targets)\n        if targets:\n            # Validate recipients and drop bad ones:\n            for target in targets:\n                result = IS_CHANNEL.match(target)\n                if result:\n                    # store valid device\n                    self.channels.append(result.group(\"name\"))\n                    continue\n\n                result = IS_USER_PUSHED_ID.match(target)\n                if result:\n                    # store valid room\n                    self.users.append(result.group(\"name\"))\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid channel/userid ({target}) specified.\",\n                )\n\n            if len(self.channels) + len(self.users) == 0:\n                # We have no valid channels or users to notify after\n                # explicitly identifying at least one.\n                msg = \"No Pushed targets to notify.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Pushed Notification.\"\"\"\n\n        # Initiaize our error tracking\n        has_error = False\n\n        # prepare JSON Object\n        payload = {\n            \"app_key\": self.app_key,\n            \"app_secret\": self.app_secret,\n            \"target_type\": \"app\",\n            \"content\": body,\n        }\n\n        # So the logic is as follows:\n        #  - if no user/channel was specified, then we just simply notify the\n        #    app.\n        #  - if there are user/channels specified, then we only alert them\n        #    while respecting throttle limits (in the event there are a lot of\n        #    entries.\n\n        if len(self.channels) + len(self.users) == 0:\n            # Just notify the app\n            return self._send(\n                payload=payload, notify_type=notify_type, **kwargs\n            )\n\n        # If our code reaches here, we want to target channels and users (by\n        # their Pushed_ID instead...\n\n        # Generate a copy of our original list\n        channels = list(self.channels)\n        users = list(self.users)\n\n        # Copy our payload\n        payload_ = dict(payload)\n        payload_[\"target_type\"] = \"channel\"\n\n        while len(channels) > 0:\n            # Get Channel\n            payload_[\"target_alias\"] = channels.pop(0)\n\n            if not self._send(\n                payload=payload_, notify_type=notify_type, **kwargs\n            ):\n\n                # toggle flag\n                has_error = True\n\n        # Copy our payload\n        payload_ = dict(payload)\n        payload_[\"target_type\"] = \"pushed_id\"\n\n        # Send all our defined User Pushed ID's\n        while len(users):\n            # Get User's Pushed ID\n            payload_[\"pushed_id\"] = users.pop(0)\n\n            if not self._send(\n                payload=payload_, notify_type=notify_type, **kwargs\n            ):\n\n                # toggle flag\n                has_error = True\n\n        return not has_error\n\n    def _send(self, payload, notify_type, **kwargs):\n        \"\"\"A lower level call that directly pushes a payload to the Pushed\n        Notification servers.\n\n        This should never be called directly; it is referenced automatically\n        through the send() function.\n        \"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        self.logger.debug(\n            \"Pushed POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Pushed Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyPushed.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Pushed notification:{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Pushed notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Pushed notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.app_key, self.app_secret)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{app_key}/{app_secret}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            app_key=self.pprint(self.app_key, privacy, safe=\"\"),\n            app_secret=self.pprint(\n                self.app_secret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            targets=\"/\".join([\n                NotifyPushed.quote(x)\n                for x in chain(\n                    # Channels are prefixed with a pound/hashtag symbol\n                    [f\"#{x}\" for x in self.channels],\n                    # Users are prefixed with an @ symbol\n                    [f\"@{x}\" for x in self.users],\n                )\n            ]),\n            params=NotifyPushed.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.channels) + len(self.users)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The first token is stored in the hostname\n        app_key = NotifyPushed.unquote(results[\"host\"])\n\n        entries = NotifyPushed.split_path(results[\"fullpath\"])\n        # Now fetch the remaining tokens\n        try:\n            app_secret = entries.pop(0)\n\n        except IndexError:\n            # Force some bad values that will get caught\n            # in parsing later\n            app_secret = None\n            app_key = None\n\n        # Get our recipients (based on remaining entries)\n        results[\"targets\"] = entries\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyPushed.parse_list(results[\"qsd\"][\"to\"])\n\n        results[\"app_key\"] = app_key\n        results[\"app_secret\"] = app_secret\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushjet.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyPushjet(NotifyBase):\n    \"\"\"A wrapper for Pushjet Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Pushjet\"\n\n    # The default protocol\n    protocol = \"pjet\"\n\n    # The default secure protocol\n    secure_protocol = \"pjets\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushjet/\"\n\n    # Disable throttle rate for Pushjet requests since they are normally\n    # local anyway (the remote/online service is no more)\n    request_rate_per_sec = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}:{port}/{secret_key}\",\n        \"{schema}://{host}/{secret_key}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{secret_key}\",\n        \"{schema}://{user}:{password}@{host}/{secret_key}\",\n    )\n\n    # Define our tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"secret_key\": {\n                \"name\": _(\"Secret Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"secret\": {\n                \"alias_of\": \"secret_key\",\n            },\n        },\n    )\n\n    def __init__(self, secret_key, **kwargs):\n        \"\"\"Initialize Pushjet Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Secret Key (associated with project)\n        self.secret_key = validate_regex(secret_key)\n        if not self.secret_key:\n            msg = (\n                f\"An invalid Pushjet Secret Key ({secret_key}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n            self.secret_key,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        default_port = 443 if self.secure else 80\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifyPushjet.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n\n        return \"{schema}://{auth}{hostname}{port}/{secret}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            secret=self.pprint(\n                self.secret_key, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            params=NotifyPushjet.urlencode(params),\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Pushjet Notification.\"\"\"\n\n        params = {\n            \"secret\": self.secret_key,\n        }\n\n        # prepare Pushjet Object\n        payload = {\n            \"message\": body,\n            \"title\": title,\n            \"link\": None,\n            \"level\": None,\n        }\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n        }\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        notify_url = \"{schema}://{host}{port}/message/\".format(\n            schema=\"https\" if self.secure else \"http\",\n            host=self.host,\n            port=f\":{self.port}\" if self.port else \"\",\n        )\n\n        self.logger.debug(\n            \"Pushjet POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Pushjet Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                params=params,\n                data=dumps(payload),\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyPushjet.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Pushjet notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Pushjet notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Pushjet \"\n                f\"notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\n\n        Syntax:\n           pjet://hostname/secret_key\n           pjet://hostname:port/secret_key\n           pjet://user:pass@hostname/secret_key\n           pjet://user:pass@hostname:port/secret_key\n           pjets://hostname/secret_key\n           pjets://hostname:port/secret_key\n           pjets://user:pass@hostname/secret_key\n           pjets://user:pass@hostname:port/secret_key\n        \"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        try:\n            # Retrieve our secret_key from the first entry in the url path\n            results[\"secret_key\"] = NotifyPushjet.split_path(\n                results[\"fullpath\"]\n            )[0]\n\n        except IndexError:\n            # no secret key specified\n            results[\"secret_key\"] = None\n\n        # Allow over-riding the secret by specifying it as an argument\n        # this allows people who have http-auth infront to login\n        # through it in addition to supporting the secret key\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            results[\"secret_key\"] = NotifyPushjet.unquote(\n                results[\"qsd\"][\"secret\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushme.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyPushMe(NotifyBase):\n    \"\"\"A wrapper for PushMe Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"PushMe\"\n\n    # The services URL\n    service_url = \"https://push.i-i.me/\"\n\n    # Insecure protocol (for those self hosted requests)\n    protocol = \"pushme\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushme/\"\n\n    # PushMe URL\n    notify_url = \"https://push.i-i.me/\"\n\n    # Define object templates\n    templates = (\"{schema}://{token}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"push_key\": {\n                \"alias_of\": \"token\",\n            },\n            \"status\": {\n                \"name\": _(\"Show Status\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n        },\n    )\n\n    def __init__(self, token, status=None, **kwargs):\n        \"\"\"Initialize PushMe Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Token (associated with project)\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = f\"An invalid PushMe Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Set Status type\n        self.status = status\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform PushMe Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Prepare our payload\n        params = {\n            \"push_key\": self.token,\n            \"title\": (\n                title\n                if not self.status\n                else f\"{self.asset.ascii(notify_type)} {title}\"\n            ),\n            \"content\": body,\n            \"type\": (\n                \"markdown\"\n                if self.notify_format == NotifyFormat.MARKDOWN\n                else \"text\"\n            ),\n        }\n\n        self.logger.debug(\n            \"PushMe POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"PushMe Payload: {params!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                params=params,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyPushMe.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send PushMe notification:{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent PushMe notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending PushMe notification.\",\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"status\": \"yes\" if self.status else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Official URLs are easy to assemble\n        return \"{schema}://{token}/?{params}\".format(\n            schema=self.protocol,\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            params=NotifyPushMe.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Store our token using the host\n        results[\"token\"] = NotifyPushMe.unquote(results[\"host\"])\n\n        # The 'token' makes it easier to use yaml configuration\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyPushMe.unquote(results[\"qsd\"][\"token\"])\n\n        elif \"push_key\" in results[\"qsd\"] and len(results[\"qsd\"][\"push_key\"]):\n            # Support 'push_key' if specified\n            results[\"token\"] = NotifyPushMe.unquote(results[\"qsd\"][\"push_key\"])\n\n        # Get status switch\n        results[\"status\"] = parse_bool(results[\"qsd\"].get(\"status\", True))\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushover.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nfrom itertools import chain\nimport re\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..common import NotifyFormat, NotifyType\nfrom ..conversion import convert_between\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Flag used as a placeholder to sending to all devices\nPUSHOVER_SEND_TO_ALL = \"ALL_DEVICES\"\n\n# Used to detect a Device\nVALIDATE_DEVICE = re.compile(r\"^\\s*(?P<device>[a-z0-9_-]{1,25})\\s*$\", re.I)\n\n\n# Priorities\nclass PushoverPriority:\n    LOW = -2\n    MODERATE = -1\n    NORMAL = 0\n    HIGH = 1\n    EMERGENCY = 2\n\n\n# Sounds\nclass PushoverSound:\n    PUSHOVER = \"pushover\"\n    BIKE = \"bike\"\n    BUGLE = \"bugle\"\n    CASHREGISTER = \"cashregister\"\n    CLASSICAL = \"classical\"\n    COSMIC = \"cosmic\"\n    FALLING = \"falling\"\n    GAMELAN = \"gamelan\"\n    INCOMING = \"incoming\"\n    INTERMISSION = \"intermission\"\n    MAGIC = \"magic\"\n    MECHANICAL = \"mechanical\"\n    PIANOBAR = \"pianobar\"\n    SIREN = \"siren\"\n    SPACEALARM = \"spacealarm\"\n    TUGBOAT = \"tugboat\"\n    ALIEN = \"alien\"\n    CLIMB = \"climb\"\n    PERSISTENT = \"persistent\"\n    ECHO = \"echo\"\n    UPDOWN = \"updown\"\n    NONE = \"none\"\n\n\nPUSHOVER_SOUNDS = (\n    PushoverSound.PUSHOVER,\n    PushoverSound.BIKE,\n    PushoverSound.BUGLE,\n    PushoverSound.CASHREGISTER,\n    PushoverSound.CLASSICAL,\n    PushoverSound.COSMIC,\n    PushoverSound.FALLING,\n    PushoverSound.GAMELAN,\n    PushoverSound.INCOMING,\n    PushoverSound.INTERMISSION,\n    PushoverSound.MAGIC,\n    PushoverSound.MECHANICAL,\n    PushoverSound.PIANOBAR,\n    PushoverSound.SIREN,\n    PushoverSound.SPACEALARM,\n    PushoverSound.TUGBOAT,\n    PushoverSound.ALIEN,\n    PushoverSound.CLIMB,\n    PushoverSound.PERSISTENT,\n    PushoverSound.ECHO,\n    PushoverSound.UPDOWN,\n    PushoverSound.NONE,\n)\n\nPUSHOVER_PRIORITIES = {\n    # Note: This also acts as a reverse lookup mapping\n    PushoverPriority.LOW: \"low\",\n    PushoverPriority.MODERATE: \"moderate\",\n    PushoverPriority.NORMAL: \"normal\",\n    PushoverPriority.HIGH: \"high\",\n    PushoverPriority.EMERGENCY: \"emergency\",\n}\n\nPUSHOVER_PRIORITY_MAP = {\n    # Maps against string 'low'\n    \"l\": PushoverPriority.LOW,\n    # Maps against string 'moderate'\n    \"m\": PushoverPriority.MODERATE,\n    # Maps against string 'normal'\n    \"n\": PushoverPriority.NORMAL,\n    # Maps against string 'high'\n    \"h\": PushoverPriority.HIGH,\n    # Maps against string 'emergency'\n    \"e\": PushoverPriority.EMERGENCY,\n    # Entries to additionally support (so more like Pushover's API)\n    \"-2\": PushoverPriority.LOW,\n    \"-1\": PushoverPriority.MODERATE,\n    \"0\": PushoverPriority.NORMAL,\n    \"1\": PushoverPriority.HIGH,\n    \"2\": PushoverPriority.EMERGENCY,\n}\n\n# Extend HTTP Error Messages\nPUSHOVER_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n\nclass NotifyPushover(NotifyBase):\n    \"\"\"A wrapper for Pushover Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Pushover\"\n\n    # The services URL\n    service_url = \"https://pushover.net/\"\n\n    # All pushover requests are secure\n    secure_protocol = \"pover\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushover/\"\n\n    # Pushover uses the http protocol with JSON requests\n    notify_url = \"https://api.pushover.net/1/messages.json\"\n\n    # Support attachments\n    attachment_support = True\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1024\n\n    # Default Pushover sound\n    default_pushover_sound = PushoverSound.PUSHOVER\n\n    # 5MB is the maximum supported image filesize as per documentation\n    # here: https://pushover.net/api#limits (Oct 5th, 2025)\n    attach_max_size_bytes = 5242880\n\n    # The regular expression of the current attachment supported mime types\n    # At this time it is only images\n    attach_supported_mime_type = r\"^image/.*\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user_key}@{token}\",\n        \"{schema}://{user_key}@{token}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user_key\": {\n                \"name\": _(\"User Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_device\": {\n                \"name\": _(\"Target Device\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z0-9_-]{1,25}$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": PUSHOVER_PRIORITIES,\n                \"default\": PushoverPriority.NORMAL,\n            },\n            \"sound\": {\n                \"name\": _(\"Sound\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z]{1,12}$\", \"i\"),\n                \"default\": PushoverSound.PUSHOVER,\n            },\n            \"url\": {\n                \"name\": _(\"URL\"),\n                \"map_to\": \"supplemental_url\",\n                \"type\": \"string\",\n            },\n            \"url_title\": {\n                \"name\": _(\"URL Title\"),\n                \"map_to\": \"supplemental_url_title\",\n                \"type\": \"string\",\n            },\n            \"retry\": {\n                \"name\": _(\"Retry\"),\n                \"type\": \"int\",\n                \"min\": 30,\n                \"default\": 900,  # 15 minutes\n            },\n            \"expire\": {\n                \"name\": _(\"Expire\"),\n                \"type\": \"int\",\n                \"min\": 0,\n                \"max\": 10800,\n                \"default\": 3600,  # 1 hour\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        user_key,\n        token,\n        targets=None,\n        priority=None,\n        sound=None,\n        retry=None,\n        expire=None,\n        supplemental_url=None,\n        supplemental_url_title=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Pushover Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Access Token (associated with project)\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = f\"An invalid Pushover Access Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # User Key (associated with project)\n        self.user_key = validate_regex(user_key)\n        if not self.user_key:\n            msg = f\"An invalid Pushover User Key ({user_key}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Track our valid devices\n        targets = parse_list(targets)\n\n        # Track any invalid entries\n        self.invalid_targets = []\n\n        if len(targets) == 0:\n            self.targets = (PUSHOVER_SEND_TO_ALL,)\n\n        else:\n            self.targets = []\n            for target in targets:\n                result = VALIDATE_DEVICE.match(target)\n                if result:\n                    # Store device information\n                    self.targets.append(result.group(\"device\"))\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid Pushover device ({target}) specified.\",\n                )\n                self.invalid_targets.append(target)\n\n        # Setup supplemental url\n        self.supplemental_url = supplemental_url\n        self.supplemental_url_title = supplemental_url_title\n\n        # Setup our sound\n        self.sound = (\n            NotifyPushover.default_pushover_sound\n            if not isinstance(sound, str)\n            else sound.lower()\n        )\n        if self.sound and self.sound not in PUSHOVER_SOUNDS:\n            msg = f\"Using custom sound specified ({sound}). \"\n            self.logger.debug(msg)\n\n        # The Priority of the message\n        self.priority = int(\n            NotifyPushover.template_args[\"priority\"][\"default\"]\n            if priority is None\n            else next(\n                (\n                    v\n                    for k, v in PUSHOVER_PRIORITY_MAP.items()\n                    if str(priority).lower().startswith(k)\n                ),\n                NotifyPushover.template_args[\"priority\"][\"default\"],\n            )\n        )\n\n        # The following are for emergency alerts\n        if self.priority == PushoverPriority.EMERGENCY:\n\n            # How often to resend notification, in seconds\n            self.retry = self.template_args[\"retry\"][\"default\"]\n            with contextlib.suppress(ValueError, TypeError):\n                # Get our retry value\n                self.retry = int(retry)\n\n            # How often to resend notification, in seconds\n            self.expire = self.template_args[\"expire\"][\"default\"]\n            with contextlib.suppress(ValueError, TypeError):\n                # Acquire our expiry value\n                self.expire = int(expire)\n\n            if self.retry < 30:\n                msg = \"Pushover retry must be at least 30 seconds.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            if self.expire < 0 or self.expire > 10800:\n                msg = (\n                    \"Pushover expire must reside in the range of \"\n                    \"0 to 10800 seconds.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Pushover Notification.\"\"\"\n\n        if not self.targets:\n            # There were no services to notify\n            self.logger.warning(\"There were no Pushover targets to notify.\")\n            return False\n\n        # prepare JSON Object\n        payload = {\n            \"token\": self.token,\n            \"user\": self.user_key,\n            \"priority\": str(self.priority),\n            \"title\": title if title else self.app_desc,\n            \"message\": body,\n            \"device\": \",\".join(self.targets),\n            \"sound\": self.sound,\n        }\n\n        if self.supplemental_url:\n            payload[\"url\"] = self.supplemental_url\n\n        if self.supplemental_url_title:\n            payload[\"url_title\"] = self.supplemental_url_title\n\n        if self.notify_format == NotifyFormat.HTML:\n            # https://pushover.net/api#html\n            payload[\"html\"] = 1\n\n        elif self.notify_format == NotifyFormat.MARKDOWN:\n            payload[\"message\"] = convert_between(\n                NotifyFormat.MARKDOWN, NotifyFormat.HTML, body\n            )\n            payload[\"html\"] = 1\n\n        if self.priority == PushoverPriority.EMERGENCY:\n            payload.update({\"retry\": self.retry, \"expire\": self.expire})\n\n        if attach and self.attachment_support:\n            # Create a copy of our payload\n            payload_ = payload.copy()\n\n            # Send with attachments\n            for no, attachment in enumerate(attach):\n                if no or not body:\n                    # To handle multiple attachments, clean up our message\n                    payload_[\"message\"] = attachment.name\n\n                if not self._send(payload_, attachment):\n                    # Mark our failure\n                    return False\n\n                # Clear our title if previously set\n                payload_[\"title\"] = \"\"\n\n                # No need to alarm for each consecutive attachment uploaded\n                # afterwards\n                payload_[\"sound\"] = PushoverSound.NONE\n\n        else:\n            # Simple send\n            return self._send(payload)\n\n        return True\n\n    def _send(self, payload, attach=None):\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n\n        if isinstance(attach, AttachBase):\n            # Perform some simple error checking\n            if not attach:\n                # We could not access the attachment\n                self.logger.error(\n                    f\"Could not access attachment {attach.url(privacy=True)}.\"\n                )\n                return False\n\n            # Perform some basic checks as we want to gracefully skip\n            # over unsupported mime types.\n            if not re.match(\n                self.attach_supported_mime_type, attach.mimetype, re.I\n            ):\n                # No problem; we just don't support this attachment\n                # type; gracefully move along\n                self.logger.debug(\n                    \"Ignored unsupported Pushover attachment\"\n                    f\" ({attach.mimetype}): {attach.url(privacy=True)}\"\n                )\n\n                attach = None\n\n            else:\n                # If we get here, we're dealing with a supported image.\n                # Verify that the filesize is okay though.\n                file_size = len(attach)\n                if not (\n                    file_size > 0 and file_size <= self.attach_max_size_bytes\n                ):\n\n                    # File size is no good\n                    self.logger.warning(\n                        f\"Pushover attachment size ({file_size}B) exceeds\"\n                        f\" limit: {attach.url(privacy=True)}\"\n                    )\n\n                    return False\n\n                self.logger.debug(\n                    f\"Posting Pushover attachment {attach.url(privacy=True)}\"\n                )\n\n        # Default Header\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Authentication\n        auth = (self.token, \"\")\n\n        # Some default values for our request object to which we'll update\n        # depending on what our payload is\n        files = None\n\n        self.logger.debug(\n            \"Pushover POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Pushover Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            # Open our attachment path if required:\n            if attach:\n                files = {\n                    \"attachment\": (\n                        attach.name,\n                        # file handle is safely closed in `finally`; inline\n                        # open is intentional\n                        open(attach.path, \"rb\"),  # noqa: SIM115\n                    )}\n\n            r = requests.post(\n                self.notify_url,\n                data=payload,\n                headers=headers,\n                files=files,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyPushover.http_response_code_lookup(\n                    r.status_code, PUSHOVER_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send Pushover notification to {}: \"\n                    \"{}{}error={}.\".format(\n                        payload[\"device\"],\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False\n\n            else:\n                self.logger.info(\n                    \"Sent Pushover notification to {}.\".format(\n                        payload[\"device\"]\n                    )\n                )\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Pushover:{} \".format(\n                    payload[\"device\"]\n                )\n                + \"notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while reading {}.\".format(\n                    attach.name if attach else \"attachment\"\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return False\n\n        finally:\n            # Close our file (if it's open) stored in the second element\n            # of our files tuple (index 1)\n            if files:\n                files[\"attachment\"][1].close()\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user_key, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"priority\": (\n                PUSHOVER_PRIORITIES[self.template_args[\"priority\"][\"default\"]]\n                if self.priority not in PUSHOVER_PRIORITIES\n                else PUSHOVER_PRIORITIES[self.priority]\n            ),\n        }\n\n        # Only add expire and retry for emergency messages,\n        # pushover ignores for all other priorities\n        if self.priority == PushoverPriority.EMERGENCY:\n            params.update({\"expire\": self.expire, \"retry\": self.retry})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Escape our devices\n        devices = \"/\".join([\n            NotifyPushover.quote(x, safe=\"\")\n            for x in chain(self.targets, self.invalid_targets)\n        ])\n\n        if devices == PUSHOVER_SEND_TO_ALL:\n            # keyword is reserved for internal usage only; it's safe to remove\n            # it from the devices list\n            devices = \"\"\n\n        return \"{schema}://{user_key}@{token}/{devices}/?{params}\".format(\n            schema=self.secure_protocol,\n            user_key=self.pprint(self.user_key, privacy, safe=\"\"),\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            devices=devices,\n            params=NotifyPushover.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Set our priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyPushover.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        # Retrieve all of our targets\n        results[\"targets\"] = NotifyPushover.split_path(results[\"fullpath\"])\n\n        # User Key is retrieved from the user\n        results[\"user_key\"] = NotifyPushover.unquote(results[\"user\"])\n\n        # Get the sound\n        if \"sound\" in results[\"qsd\"] and len(results[\"qsd\"][\"sound\"]):\n            results[\"sound\"] = NotifyPushover.unquote(results[\"qsd\"][\"sound\"])\n\n        # Get the supplementary url\n        if \"url\" in results[\"qsd\"] and len(results[\"qsd\"][\"url\"]):\n            results[\"supplemental_url\"] = NotifyPushover.unquote(\n                results[\"qsd\"][\"url\"]\n            )\n        if \"url_title\" in results[\"qsd\"] and len(results[\"qsd\"][\"url_title\"]):\n            results[\"supplemental_url_title\"] = results[\"qsd\"][\"url_title\"]\n\n        # Get expire and retry\n        if \"expire\" in results[\"qsd\"] and len(results[\"qsd\"][\"expire\"]):\n            results[\"expire\"] = results[\"qsd\"][\"expire\"]\n        if \"retry\" in results[\"qsd\"] and len(results[\"qsd\"][\"retry\"]):\n            results[\"retry\"] = results[\"qsd\"][\"retry\"]\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyPushover.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Token\n        results[\"token\"] = NotifyPushover.unquote(results[\"host\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushplus.py",
    "content": "#\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Details at:\n# https://www.pushplus.plus/doc/guide/api.html\n\nimport json\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyPushplus(NotifyBase):\n    \"\"\"A wrapper for Pushplus Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Pushplus\")\n\n    # The services URL\n    service_url = \"https://www.pushplus.plus/\"\n\n    # The default secure protocol\n    secure_protocol = \"pushplus\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushplus/\"\n\n    # URL used to send notifications with\n    notify_url = \"https://www.pushplus.plus/send\"\n\n    templates = (\"{schema}://{token}\",)\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"User Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9_-]{32,64}$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize Pushplus Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Pushplus token ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n        return (\n            f\"{self.secure_protocol}://\"\n            f\"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/\"\n            f\"?{self.urlencode(params)}\"\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns a unique identifier for this plugin instance.\"\"\"\n        return (self.secure_protocol, self.token)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send a Pushplus Notification.\"\"\"\n        payload = {\n            \"token\": self.token,\n            \"title\": title if title else body,\n            \"content\": body,\n        }\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            response = requests.post(\n                self.notify_url,\n                headers=headers,\n                data=json.dumps(payload),\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if response.status_code != requests.codes.ok:\n                self.logger.warning(\n                    \"Pushplus notification failed: %d - %s\",\n                    response.status_code,\n                    response.text,\n                )\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(f\"Pushplus Exception: {e}\")\n            return False\n\n        self.logger.info(\"Pushplus notification sent successfully.\")\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns arguments to re-instantiate the\n        object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            return results\n\n        if \"token\" in results[\"qsd\"] and results[\"qsd\"][\"token\"]:\n            results[\"token\"] = NotifyPushplus.unquote(results[\"qsd\"][\"token\"])\n        else:\n            results[\"token\"] = NotifyPushplus.unquote(results[\"host\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"Parse native Pushplus-style URL.\"\"\"\n        match = re.match(\n            r\"^https://www\\.pushplus\\.plus/send\\?token=([a-z0-9_-]+)$\",\n            url,\n            re.I,\n        )\n        if not match:\n            return None\n\n        return NotifyPushplus.parse_url(\n            f\"{NotifyPushplus.secure_protocol}://{match.group(1)}\"\n        )\n"
  },
  {
    "path": "apprise/plugins/pushsafer.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\nimport logging\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_list, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n\nclass PushSaferSound:\n    \"\"\"Defines all of the supported PushSafe sounds.\"\"\"\n\n    # Silent\n    SILENT = 0\n    # Ahem (IM)\n    AHEM = 1\n    # Applause (Mail)\n    APPLAUSE = 2\n    # Arrow (Reminder)\n    ARROW = 3\n    # Baby (SMS)\n    BABY = 4\n    # Bell (Alarm)\n    BELL = 5\n    # Bicycle (Alarm2)\n    BICYCLE = 6\n    # Boing (Alarm3)\n    BOING = 7\n    # Buzzer (Alarm4)\n    BUZZER = 8\n    # Camera (Alarm5)\n    CAMERA = 9\n    # Car Horn (Alarm6)\n    CAR_HORN = 10\n    # Cash Register (Alarm7)\n    CASH_REGISTER = 11\n    # Chime (Alarm8)\n    CHIME = 12\n    # Creaky Door (Alarm9)\n    CREAKY_DOOR = 13\n    # Cuckoo Clock (Alarm10)\n    CUCKOO_CLOCK = 14\n    # Disconnect (Call)\n    DISCONNECT = 15\n    # Dog (Call2)\n    DOG = 16\n    # Doorbell (Call3)\n    DOORBELL = 17\n    # Fanfare (Call4)\n    FANFARE = 18\n    # Gun Shot (Call5)\n    GUN_SHOT = 19\n    # Honk (Call6)\n    HONK = 20\n    # Jaw Harp (Call7)\n    JAW_HARP = 21\n    # Morse (Call8)\n    MORSE = 22\n    # Electricity (Call9)\n    ELECTRICITY = 23\n    # Radio Tuner (Call10)\n    RADIO_TURNER = 24\n    # Sirens\n    SIRENS = 25\n    # Military Trumpets\n    MILITARY_TRUMPETS = 26\n    # Ufo\n    UFO = 27\n    # Whah Whah Whah\n    LONG_WHAH = 28\n    # Man Saying Goodbye\n    GOODBYE = 29\n    # Man Saying Hello\n    HELLO = 30\n    # Man Saying No\n    NO = 31\n    # Man Saying Ok\n    OKAY = 32\n    # Man Saying Ooohhhweee\n    OOOHHHWEEE = 33\n    # Man Saying Warning\n    WARNING = 34\n    # Man Saying Welcome\n    WELCOME = 35\n    # Man Saying Yeah\n    YEAH = 36\n    # Man Saying Yes\n    YES = 37\n    # Beep short\n    BEEP1 = 38\n    # Weeeee short\n    WEEE = 39\n    # Cut in and out short\n    CUTINOUT = 40\n    # Finger flicking glas short\n    FLICK_GLASS = 41\n    # Wa Wa Waaaa short\n    SHORT_WHAH = 42\n    # Laser short\n    LASER = 43\n    # Wind Chime short\n    WIND_CHIME = 44\n    # Echo short\n    ECHO = 45\n    # Zipper short\n    ZIPPER = 46\n    # HiHat short\n    HIHAT = 47\n    # Beep 2 short\n    BEEP2 = 48\n    # Beep 3 short\n    BEEP3 = 49\n    # Beep 4 short\n    BEEP4 = 50\n    # The Alarm is armed\n    ALARM_ARMED = 51\n    # The Alarm is disarmed\n    ALARM_DISARMED = 52\n    # The Backup is ready\n    BACKUP_READY = 53\n    # The Door is closed\n    DOOR_CLOSED = 54\n    # The Door is opend\n    DOOR_OPENED = 55\n    # The Window is closed\n    WINDOW_CLOSED = 56\n    # The Window is open\n    WINDOW_OPEN = 57\n    # The Light is off\n    LIGHT_ON = 58\n    # The Light is on\n    LIGHT_OFF = 59\n    # The Doorbell rings\n    DOORBELL_RANG = 60\n\n\nPUSHSAFER_SOUND_MAP = {\n    # Device Default,\n    \"silent\": PushSaferSound.SILENT,\n    \"ahem\": PushSaferSound.AHEM,\n    \"applause\": PushSaferSound.APPLAUSE,\n    \"arrow\": PushSaferSound.ARROW,\n    \"baby\": PushSaferSound.BABY,\n    \"bell\": PushSaferSound.BELL,\n    \"bicycle\": PushSaferSound.BICYCLE,\n    \"bike\": PushSaferSound.BICYCLE,\n    \"boing\": PushSaferSound.BOING,\n    \"buzzer\": PushSaferSound.BUZZER,\n    \"camera\": PushSaferSound.CAMERA,\n    \"carhorn\": PushSaferSound.CAR_HORN,\n    \"horn\": PushSaferSound.CAR_HORN,\n    \"cashregister\": PushSaferSound.CASH_REGISTER,\n    \"chime\": PushSaferSound.CHIME,\n    \"creakydoor\": PushSaferSound.CREAKY_DOOR,\n    \"cuckooclock\": PushSaferSound.CUCKOO_CLOCK,\n    \"cuckoo\": PushSaferSound.CUCKOO_CLOCK,\n    \"disconnect\": PushSaferSound.DISCONNECT,\n    \"dog\": PushSaferSound.DOG,\n    \"doorbell\": PushSaferSound.DOORBELL,\n    \"fanfare\": PushSaferSound.FANFARE,\n    \"gunshot\": PushSaferSound.GUN_SHOT,\n    \"honk\": PushSaferSound.HONK,\n    \"jawharp\": PushSaferSound.JAW_HARP,\n    \"morse\": PushSaferSound.MORSE,\n    \"electric\": PushSaferSound.ELECTRICITY,\n    \"radiotuner\": PushSaferSound.RADIO_TURNER,\n    \"sirens\": PushSaferSound.SIRENS,\n    \"militarytrumpets\": PushSaferSound.MILITARY_TRUMPETS,\n    \"military\": PushSaferSound.MILITARY_TRUMPETS,\n    \"trumpets\": PushSaferSound.MILITARY_TRUMPETS,\n    \"ufo\": PushSaferSound.UFO,\n    \"whahwhah\": PushSaferSound.LONG_WHAH,\n    \"whah\": PushSaferSound.SHORT_WHAH,\n    \"goodye\": PushSaferSound.GOODBYE,\n    \"hello\": PushSaferSound.HELLO,\n    \"no\": PushSaferSound.NO,\n    \"okay\": PushSaferSound.OKAY,\n    \"ok\": PushSaferSound.OKAY,\n    \"ooohhhweee\": PushSaferSound.OOOHHHWEEE,\n    \"warn\": PushSaferSound.WARNING,\n    \"warning\": PushSaferSound.WARNING,\n    \"welcome\": PushSaferSound.WELCOME,\n    \"yeah\": PushSaferSound.YEAH,\n    \"yes\": PushSaferSound.YES,\n    \"beep\": PushSaferSound.BEEP1,\n    \"beep1\": PushSaferSound.BEEP1,\n    \"weee\": PushSaferSound.WEEE,\n    \"wee\": PushSaferSound.WEEE,\n    \"cutinout\": PushSaferSound.CUTINOUT,\n    \"flickglass\": PushSaferSound.FLICK_GLASS,\n    \"laser\": PushSaferSound.LASER,\n    \"windchime\": PushSaferSound.WIND_CHIME,\n    \"echo\": PushSaferSound.ECHO,\n    \"zipper\": PushSaferSound.ZIPPER,\n    \"hihat\": PushSaferSound.HIHAT,\n    \"beep2\": PushSaferSound.BEEP2,\n    \"beep3\": PushSaferSound.BEEP3,\n    \"beep4\": PushSaferSound.BEEP4,\n    \"alarmarmed\": PushSaferSound.ALARM_ARMED,\n    \"armed\": PushSaferSound.ALARM_ARMED,\n    \"alarmdisarmed\": PushSaferSound.ALARM_DISARMED,\n    \"disarmed\": PushSaferSound.ALARM_DISARMED,\n    \"backupready\": PushSaferSound.BACKUP_READY,\n    \"dooropen\": PushSaferSound.DOOR_OPENED,\n    \"dopen\": PushSaferSound.DOOR_OPENED,\n    \"doorclosed\": PushSaferSound.DOOR_CLOSED,\n    \"dclosed\": PushSaferSound.DOOR_CLOSED,\n    \"windowopen\": PushSaferSound.WINDOW_OPEN,\n    \"wopen\": PushSaferSound.WINDOW_OPEN,\n    \"windowclosed\": PushSaferSound.WINDOW_CLOSED,\n    \"wclosed\": PushSaferSound.WINDOW_CLOSED,\n    \"lighton\": PushSaferSound.LIGHT_ON,\n    \"lon\": PushSaferSound.LIGHT_ON,\n    \"lightoff\": PushSaferSound.LIGHT_OFF,\n    \"loff\": PushSaferSound.LIGHT_OFF,\n    \"doorbellrang\": PushSaferSound.DOORBELL_RANG,\n}\n\n\n# Priorities\nclass PushSaferPriority:\n    LOW = -2\n    MODERATE = -1\n    NORMAL = 0\n    HIGH = 1\n    EMERGENCY = 2\n\n\nPUSHSAFER_PRIORITIES = (\n    PushSaferPriority.LOW,\n    PushSaferPriority.MODERATE,\n    PushSaferPriority.NORMAL,\n    PushSaferPriority.HIGH,\n    PushSaferPriority.EMERGENCY,\n)\n\nPUSHSAFER_PRIORITY_MAP = {\n    # short for 'low'\n    \"low\": PushSaferPriority.LOW,\n    # short for 'medium'\n    \"medium\": PushSaferPriority.MODERATE,\n    # short for 'normal'\n    \"normal\": PushSaferPriority.NORMAL,\n    # short for 'high'\n    \"high\": PushSaferPriority.HIGH,\n    # short for 'emergency'\n    \"emergency\": PushSaferPriority.EMERGENCY,\n}\n\n# Identify the priority ou want to designate as the fall back\nDEFAULT_PRIORITY = \"normal\"\n\n\n# Vibrations\nclass PushSaferVibration:\n    \"\"\"Defines the acceptable vibration settings for notification.\"\"\"\n\n    # x1\n    LOW = 1\n    # x2\n    NORMAL = 2\n    # x3\n    HIGH = 3\n\n\n# Identify all of the vibrations in one place\nPUSHSAFER_VIBRATIONS = (\n    PushSaferVibration.LOW,\n    PushSaferVibration.NORMAL,\n    PushSaferVibration.HIGH,\n)\n\n# At this time, the following pictures can be attached to each notification\n# at one time. When more are supported, just add their argument below\nPICTURE_PARAMETER = (\n    \"p\",\n    \"p2\",\n    \"p3\",\n)\n\n\n# Flag used as a placeholder to sending to all devices\nPUSHSAFER_SEND_TO_ALL = \"a\"\n\n\nclass NotifyPushSafer(NotifyBase):\n    \"\"\"A wrapper for PushSafer Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Pushsafer\"\n\n    # The services URL\n    service_url = \"https://www.pushsafer.com/\"\n\n    # The default insecure protocol\n    protocol = \"psafer\"\n\n    # The default secure protocol\n    secure_protocol = \"psafers\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Number of requests to a allow per second\n    request_rate_per_sec = 1.2\n\n    # The icon ID of 25 looks like a megaphone\n    default_pushsafer_icon = 25\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushsafer/\"\n\n    # Defines the hostname to post content to; since this service supports\n    # both insecure and secure methods, we set the {schema} just before we\n    # post the message upstream.\n    notify_url = \"{schema}://www.pushsafer.com/api\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{privatekey}\",\n        \"{schema}://{privatekey}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"privatekey\": {\n                \"name\": _(\"Private Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_device\": {\n                \"name\": _(\"Target Device\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": PUSHSAFER_PRIORITIES,\n            },\n            \"sound\": {\n                \"name\": _(\"Sound\"),\n                \"type\": \"choice:string\",\n                \"values\": PUSHSAFER_SOUND_MAP,\n            },\n            \"vibration\": {\n                \"name\": _(\"Vibration\"),\n                \"type\": \"choice:int\",\n                \"values\": PUSHSAFER_VIBRATIONS,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        privatekey,\n        targets=None,\n        priority=None,\n        sound=None,\n        vibration=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize PushSafer Object.\"\"\"\n        super().__init__(**kwargs)\n\n        #\n        # Priority\n        #\n        try:\n            # Acquire our priority if we can:\n            #  - We accept both the integer form as well as a string\n            #    representation\n            self.priority = int(priority)\n\n        except TypeError:\n            # NoneType means use Default; this is an okay exception\n            self.priority = None\n\n        except ValueError:\n            # Input is a string; attempt to get the lookup from our\n            # priority mapping\n            priority = priority.lower().strip()\n\n            # This little bit of black magic allows us to match against\n            # low, lo, l (for low);\n            # normal, norma, norm, nor, no, n (for normal)\n            # ... etc\n            match = (\n                next(\n                    (\n                        key\n                        for key in PUSHSAFER_PRIORITY_MAP\n                        if key.startswith(priority)\n                    ),\n                    None,\n                )\n                if priority\n                else None\n            )\n\n            # Now test to see if we got a match\n            if not match:\n                msg = (\n                    \"An invalid PushSafer priority \"\n                    f\"({priority}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n            # store our successfully looked up priority\n            self.priority = PUSHSAFER_PRIORITY_MAP[match]\n\n        if (\n            self.priority is not None\n            and self.priority not in PUSHSAFER_PRIORITY_MAP.values()\n        ):\n            msg = f\"An invalid PushSafer priority ({priority}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        #\n        # Sound\n        #\n        try:\n            # Acquire our sound if we can:\n            #  - We accept both the integer form as well as a string\n            #    representation\n            self.sound = int(sound)\n\n        except TypeError:\n            # NoneType means use Default; this is an okay exception\n            self.sound = None\n\n        except ValueError:\n            # Input is a string; attempt to get the lookup from our\n            # sound mapping\n            sound = sound.lower().strip()\n\n            # This little bit of black magic allows us to match against\n            # against multiple versions of the same string\n            # ... etc\n            match = (\n                next(\n                    (\n                        key\n                        for key in PUSHSAFER_SOUND_MAP\n                        if key.startswith(sound)\n                    ),\n                    None,\n                )\n                if sound\n                else None\n            )\n\n            # Now test to see if we got a match\n            if not match:\n                msg = f\"An invalid PushSafer sound ({sound}) was specified.\"\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n            # store our successfully looked up sound\n            self.sound = PUSHSAFER_SOUND_MAP[match]\n\n        if (\n            self.sound is not None\n            and self.sound not in PUSHSAFER_SOUND_MAP.values()\n        ):\n            msg = f\"An invalid PushSafer sound ({sound}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        #\n        # Vibration\n        #\n        try:\n            # Use defined integer as is if defined, no further error checking\n            # is performed\n            self.vibration = int(vibration)\n\n        except TypeError:\n            # NoneType means use Default; this is an okay exception\n            self.vibration = None\n\n        except ValueError:\n            msg = (\n                f\"An invalid PushSafer vibration ({vibration}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        if self.vibration and self.vibration not in PUSHSAFER_VIBRATIONS:\n            msg = (\n                f\"An invalid PushSafer vibration ({vibration}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        #\n        # Private Key (associated with project)\n        #\n        self.privatekey = validate_regex(privatekey)\n        if not self.privatekey:\n            msg = (\n                \"An invalid PushSafer Private Key \"\n                f\"({privatekey}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.targets = parse_list(targets)\n        if len(self.targets) == 0:\n            self.targets = (PUSHSAFER_SEND_TO_ALL,)\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform PushSafer Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Initialize our list of attachments\n        attachments = []\n\n        if attach and self.attachment_support:\n            # We need to upload our payload first so that we can source it\n            # in remaining messages\n            for no, attachment in enumerate(attach, start=1):\n                # prepare payload\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access PushSafer attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                if not attachment.mimetype.startswith(\"image/\"):\n                    # Attachment not supported; continue peacefully\n                    self.logger.debug(\n                        \"Ignoring unsupported PushSafer attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    continue\n\n                self.logger.debug(\n                    \"Posting PushSafer attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n                try:\n                    # Output must be in a DataURL format (that's what\n                    # PushSafer calls it):\n                    attachments.append((\n                        (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                        f\"data:{attachment.mimetype};base64,{attachment.base64()}\",\n                    ))\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access PushSafer attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending PushSafer attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n        while len(targets):\n            recipient = targets.pop(0)\n\n            # prepare payload\n            payload = {\n                \"t\": title,\n                \"m\": body,\n                # Our default icon to use\n                \"i\": self.default_pushsafer_icon,\n                # Notification Color\n                \"c\": self.color(notify_type),\n                # Target Recipient\n                \"d\": recipient,\n            }\n\n            if self.sound is not None:\n                # Only apply sound setting if it was specified\n                payload[\"s\"] = str(self.sound)\n\n            if self.vibration is not None:\n                # Only apply vibration setting\n                payload[\"v\"] = str(self.vibration)\n\n            if not attachments:\n                okay, _response = self._send(payload)\n                if not okay:\n                    has_error = True\n                    continue\n\n                self.logger.info(\n                    f'Sent PushSafer notification to \"{recipient}\".'\n                )\n\n            else:\n                # Create a copy of our payload object\n                payload_ = payload.copy()\n\n                for idx in range(0, len(attachments), len(PICTURE_PARAMETER)):\n                    # Send our attachments to our same user (already prepared\n                    # as our payload object)\n                    for c, attachment in enumerate(\n                        attachments[idx : idx + len(PICTURE_PARAMETER)]\n                    ):\n\n                        # Get our attachment information\n                        filename, dataurl = attachment\n                        payload_.update({PICTURE_PARAMETER[c]: dataurl})\n\n                        self.logger.debug(\n                            f'Added attachment ({filename}) to \"{recipient}\".'\n                        )\n\n                    okay, _response = self._send(payload_)\n                    if not okay:\n                        has_error = True\n                        continue\n\n                    self.logger.info(\n                        f\"Sent PushSafer attachment ({filename}) to\"\n                        f' \"{recipient}\".'\n                    )\n\n                    # More then the maximum messages shouldn't cause all of\n                    # the text to loop on future iterations\n                    payload_ = payload.copy()\n                    payload_[\"t\"] = \"\"\n                    payload_[\"m\"] = \"...\"\n\n        return not has_error\n\n    def _send(self, payload, **kwargs):\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Prepare the notification URL to post to\n        notify_url = self.notify_url.format(\n            schema=\"https\" if self.secure else \"http\"\n        )\n\n        # Store the payload key\n        payload[\"k\"] = self.privatekey\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                \"PushSafer POST URL:\"\n                f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(\n                \"PushSafer Payload: %s\", sanitize_payload(payload))\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # Default response type\n        response = None\n\n        # Initialize our Pushsafer expected responses\n        code = None\n        str_ = \"Unknown\"\n\n        try:\n            # Open our attachment path if required:\n            r = requests.post(\n                notify_url,\n                data=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                response = loads(r.content)\n                code = response.get(\"status\")\n                str_ = (\n                    response.get(\"success\", str_)\n                    if code == 1\n                    else response.get(\"error\", str_)\n                )\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n\n                # Fall back to the existing unparsed value\n                response = r.content\n\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n                # We had a problem\n                status_str = NotifyPushSafer.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to deliver payload to PushSafer:\"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False, response\n\n            elif code != 1:\n                # It's a bit backwards, but:\n                #    1 is returned if we succeed\n                #    0 is returned if we fail\n                self.logger.warning(\n                    f\"Failed to deliver payload to PushSafer; error={str_}.\"\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                return False, response\n\n            # otherwise we were successful\n            return True, response\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred communicating with PushSafer.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False, response\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.privatekey,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if self.priority is not None:\n            # Store our priority; but only if it was specified\n            params[\"priority\"] = next(\n                (\n                    key\n                    for key, value in PUSHSAFER_PRIORITY_MAP.items()\n                    if value == self.priority\n                ),\n                DEFAULT_PRIORITY,\n            )  # pragma: no cover\n\n        if self.sound is not None:\n            # Store our sound; but only if it was specified\n            params[\"sound\"] = next(\n                (\n                    key\n                    for key, value in PUSHSAFER_SOUND_MAP.items()\n                    if value == self.sound\n                ),\n                \"\",\n            )  # pragma: no cover\n\n        if self.vibration is not None:\n            # Store our vibration; but only if it was specified\n            params[\"vibration\"] = str(self.vibration)\n\n        targets = \"/\".join([NotifyPushSafer.quote(x) for x in self.targets])\n        if targets == PUSHSAFER_SEND_TO_ALL:\n            # keyword is reserved for internal usage only; it's safe to remove\n            # it from the recipients list\n            targets = \"\"\n\n        return \"{schema}://{privatekey}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            privatekey=self.pprint(self.privatekey, privacy, safe=\"\"),\n            targets=targets,\n            params=NotifyPushSafer.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Fetch our targets\n        results[\"targets\"] = NotifyPushSafer.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyPushSafer.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Setup the token; we store it in Private Key for global\n        # plugin consistency with naming conventions\n        results[\"privatekey\"] = NotifyPushSafer.unquote(results[\"host\"])\n\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifyPushSafer.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        if \"sound\" in results[\"qsd\"] and len(results[\"qsd\"][\"sound\"]):\n            results[\"sound\"] = NotifyPushSafer.unquote(results[\"qsd\"][\"sound\"])\n\n        if \"vibration\" in results[\"qsd\"] and len(results[\"qsd\"][\"vibration\"]):\n            results[\"vibration\"] = NotifyPushSafer.unquote(\n                results[\"qsd\"][\"vibration\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/pushy.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API reference: https://pushy.me/docs/api/send-notifications\nfrom itertools import chain\nfrom json import dumps, loads\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Used to detect a Device and Topic\nVALIDATE_DEVICE = re.compile(r\"^@(?P<device>[a-z0-9]+)$\", re.I)\nVALIDATE_TOPIC = re.compile(r\"^[#]?(?P<topic>[a-z0-9]+)$\", re.I)\n\n# Extend HTTP Error Messages\nPUSHY_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n\nclass NotifyPushy(NotifyBase):\n    \"\"\"A wrapper for Pushy Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Pushy\"\n\n    # The services URL\n    service_url = \"https://pushy.me/\"\n\n    # All Pushy requests are secure\n    secure_protocol = \"pushy\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/pushy/\"\n\n    # Pushy uses the http protocol with JSON requests\n    notify_url = \"https://api.pushy.me/push?api_key={apikey}\"\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 4096\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"Secret API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_device\": {\n                \"name\": _(\"Target Device\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"target_topic\": {\n                \"name\": _(\"Target Topic\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"sound\": {\n                # Specify something like ping.aiff\n                \"name\": _(\"Sound\"),\n                \"type\": \"string\",\n            },\n            \"badge\": {\n                \"name\": _(\"Badge\"),\n                \"type\": \"int\",\n                \"min\": 0,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"key\": {\n                \"alias_of\": \"apikey\",\n            },\n        },\n    )\n\n    def __init__(self, apikey, targets=None, sound=None, badge=None, **kwargs):\n        \"\"\"Initialize Pushy Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Access Token (associated with project)\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid Pushy Secret API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Get our targets\n        self.devices = []\n        self.topics = []\n\n        for target in parse_list(targets):\n            result = VALIDATE_TOPIC.match(target)\n            if result:\n                self.topics.append(result.group(\"topic\"))\n                continue\n\n            result = VALIDATE_DEVICE.match(target)\n            if result:\n                self.devices.append(result.group(\"device\"))\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid topic/device  ({target}) specified.\",\n            )\n\n        # Setup our sound\n        self.sound = sound\n\n        # Badge\n        try:\n            # Acquire our badge count if we can:\n            #  - We accept both the integer form as well as a string\n            #    representation\n            self.badge = int(badge)\n            if self.badge < 0:\n                raise ValueError()\n\n        except TypeError:\n            # NoneType means use Default; this is an okay exception\n            self.badge = None\n\n        except ValueError:\n            self.badge = None\n            self.logger.warning(\n                \"The specified Pushy badge ({}) is not valid \", badge\n            )\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Pushy Notification.\"\"\"\n\n        if len(self.topics) + len(self.devices) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no Pushy targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Default Header\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accepts\": \"application/json\",\n        }\n\n        # Our URL\n        notify_url = self.notify_url.format(apikey=self.apikey)\n\n        # Default content response object\n        content = {}\n\n        # Create a copy of targets (topics and devices)\n        targets = list(self.topics) + list(self.devices)\n        while len(targets):\n            target = targets.pop(0)\n\n            # prepare JSON Object\n            payload = {\n                # Mandatory fields\n                \"to\": target,\n                \"data\": {\n                    \"message\": body,\n                },\n                \"notification\": {\n                    \"body\": body,\n                },\n            }\n\n            # Optional payload items\n            if title:\n                payload[\"notification\"][\"title\"] = title\n\n            if self.sound:\n                payload[\"notification\"][\"sound\"] = self.sound\n\n            if self.badge is not None:\n                payload[\"notification\"][\"badge\"] = self.badge\n\n            self.logger.debug(\n                \"Pushy POST URL:\"\n                f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Pushy Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # Sample response\n                # See: https://pushy.me/docs/api/send-notifications\n                # {\n                #     \"success\": true,\n                #     \"id\": \"5ea9b214b47cad768a35f13a\",\n                #     \"info\": {\n                #         \"devices\": 1\n                #         \"failed\": ['abc']\n                #     }\n                # }\n                try:\n                    content = loads(r.content)\n\n                except (AttributeError, TypeError, ValueError):\n                    # ValueError = r.content is Unparsable\n                    # TypeError = r.content is None\n                    # AttributeError = r is None\n                    content = {\n                        \"success\": False,\n                        \"id\": \"\",\n                        \"info\": {},\n                    }\n\n                if r.status_code != requests.codes.ok or not content.get(\n                    \"success\"\n                ):\n\n                    # We had a problem\n                    status_str = NotifyPushy.http_response_code_lookup(\n                        r.status_code, PUSHY_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Pushy notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Pushy notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Pushy:%s \"\n                    \"notification\",\n                    target,\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {}\n        if self.sound:\n            params[\"sound\"] = self.sound\n\n        if self.badge is not None:\n            params[\"badge\"] = str(self.badge)\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{apikey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join([\n                NotifyPushy.quote(x, safe=\"@#\")\n                for x in chain(\n                    # Topics are prefixed with a pound/hashtag symbol\n                    [f\"#{x}\" for x in self.topics],\n                    # Devices\n                    [f\"@{x}\" for x in self.devices],\n                )\n            ]),\n            params=NotifyPushy.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.topics) + len(self.devices)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Token\n        results[\"apikey\"] = NotifyPushy.unquote(results[\"host\"])\n\n        # Retrieve all of our targets\n        results[\"targets\"] = NotifyPushy.split_path(results[\"fullpath\"])\n\n        # Get the sound\n        if \"sound\" in results[\"qsd\"] and len(results[\"qsd\"][\"sound\"]):\n            results[\"sound\"] = NotifyPushy.unquote(results[\"qsd\"][\"sound\"])\n\n        # Badge\n        if \"badge\" in results[\"qsd\"] and results[\"qsd\"][\"badge\"]:\n            results[\"badge\"] = NotifyPushy.unquote(\n                results[\"qsd\"][\"badge\"].strip()\n            )\n\n        # Support key variable to store Secret API Key\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            results[\"apikey\"] = results[\"qsd\"][\"key\"]\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyPushy.parse_list(results[\"qsd\"][\"to\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/qq.py",
    "content": "#\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Assumes QQ Push API provided by third-party bridge like message-pusher\n\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyQQ(NotifyBase):\n    \"\"\"A wrapper for QQ Push Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"QQ Push\")\n\n    # The services URL\n    service_url = \"https://github.com/songquanpeng/message-pusher\"\n\n    # The default secure protocol\n    secure_protocol = \"qq\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/qq/\"\n\n    # URL used to send notifications with\n    notify_url = \"https://qmsg.zendee.cn/send/\"\n\n    templates = (\"{schema}://{token}\",)\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"User Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]{24,64}$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize QQ Push Object.\n\n        Args:\n            token (str): User push token from QQ Push provider (e.g., Qmsg)\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The QQ Push token ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.webhook_url = f\"{self.notify_url}{self.token}\"\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n        return (\n            f\"{self.secure_protocol}://\"\n            f\"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/\"\n            f\"?{self.urlencode(params)}\"\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns a unique identifier for this plugin instance.\"\"\"\n        return (self.secure_protocol, self.token)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send a QQ Push Notification.\"\"\"\n        payload = {\"msg\": f\"{title}\\n{body}\" if title else body}\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n\n        self.throttle()\n        try:\n            response = requests.post(\n                self.webhook_url,\n                headers=headers,\n                data=payload,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if response.status_code != requests.codes.ok:\n                self.logger.warning(\n                    \"QQ Push notification failed: %d - %s\",\n                    response.status_code,\n                    response.text,\n                )\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(f\"QQ Push Exception: {e}\")\n            return False\n\n        self.logger.info(\"QQ Push notification sent successfully.\")\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns arguments to re-instantiate the\n        object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            return results\n\n        if \"token\" in results[\"qsd\"] and results[\"qsd\"][\"token\"]:\n            results[\"token\"] = NotifyQQ.unquote(results[\"qsd\"][\"token\"])\n        else:\n            results[\"token\"] = NotifyQQ.unquote(results[\"host\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"Parse native QQ push-style URL into Apprise format.\"\"\"\n        match = re.match(\n            r\"^https://qmsg\\.zendee\\.cn/send/([a-z0-9]+)$\", url, re.I\n        )\n        if not match:\n            return None\n\n        return NotifyQQ.parse_url(\n            f\"{NotifyQQ.secure_protocol}://{match.group(1)}\"\n        )\n"
  },
  {
    "path": "apprise/plugins/reddit.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# 1. Visit https://www.reddit.com/prefs/apps and scroll to the bottom\n# 2. Click on the button that reads 'are you a developer? create an app...'\n# 3. Set the mode to `script`,\n# 4. Provide a `name`, `description`, `redirect uri` and save it.\n# 5. Once the bot is saved, you'll be given a ID (next to the the bot name)\n#    and a Secret.\n\n# The App ID will look something like this: YWARPXajkk645m\n# The App Secret will look something like this: YZGKc5YNjq3BsC-bf7oBKalBMeb1xA\n# The App will also have a location where you can identify the users\n# who have access (identified as Developers) to the app itself. You will\n# additionally need these credentials authenticate with.\n\n# With this information you'll be able to form the URL:\n# reddit://{user}:{password}@{app_id}/{app_secret}\n\n# All of the documentation needed to work with the Reddit API can be found\n# here:\n#   - https://www.reddit.com/dev/api/\n#   - https://www.reddit.com/dev/api/#POST_api_submit\n#   - https://github.com/reddit-archive/reddit/wiki/API\nfrom datetime import datetime, timedelta, timezone\nfrom json import loads\n\nimport requests\n\nfrom .. import __title__, __version__\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nREDDIT_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token\",\n}\n\n\nclass RedditMessageKind:\n    \"\"\"Define the kinds of messages supported.\"\"\"\n\n    # Attempt to auto-detect the type prior to passing along the message to\n    # Reddit\n    AUTO = \"auto\"\n\n    # A common message\n    SELF = \"self\"\n\n    # A Hyperlink\n    LINK = \"link\"\n\n\nREDDIT_MESSAGE_KINDS = (\n    RedditMessageKind.AUTO,\n    RedditMessageKind.SELF,\n    RedditMessageKind.LINK,\n)\n\n\nclass NotifyReddit(NotifyBase):\n    \"\"\"A wrapper for Notify Reddit Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Reddit\"\n\n    # The services URL\n    service_url = \"https://reddit.com\"\n\n    # The default secure protocol\n    secure_protocol = \"reddit\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/reddit/\"\n\n    # The maximum size of the message\n    body_maxlen = 6000\n\n    # Maximum title length as defined by the Reddit API\n    title_maxlen = 300\n\n    # Default to markdown\n    notify_format = NotifyFormat.MARKDOWN\n\n    # The default Notification URL to use\n    auth_url = \"https://www.reddit.com/api/v1/access_token\"\n    submit_url = \"https://oauth.reddit.com/api/submit\"\n\n    # Reddit is kind enough to return how many more requests we're allowed to\n    # continue to make within it's header response as:\n    # X-RateLimit-Reset: The epoc time (in seconds) we can expect our\n    #                    rate-limit to be reset.\n    # X-RateLimit-Remaining: an integer identifying how many requests we're\n    #                        still allow to make.\n    request_rate_per_sec = 0\n\n    # Taken right from google.auth.helpers:\n    clock_skew = timedelta(seconds=10)\n\n    # 1 hour in seconds (the lifetime of our token)\n    access_token_lifetime_sec = timedelta(seconds=3600)\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}:{password}@{app_id}/{app_secret}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"app_id\": {\n                \"name\": _(\"Application ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n            },\n            \"app_secret\": {\n                \"name\": _(\"Application Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n            },\n            \"target_subreddit\": {\n                \"name\": _(\"Target Subreddit\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"kind\": {\n                \"name\": _(\"Kind\"),\n                \"type\": \"choice:string\",\n                \"values\": REDDIT_MESSAGE_KINDS,\n                \"default\": RedditMessageKind.AUTO,\n            },\n            \"flair_id\": {\n                \"name\": _(\"Flair ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"flair_id\",\n            },\n            \"flair_text\": {\n                \"name\": _(\"Flair Text\"),\n                \"type\": \"string\",\n                \"map_to\": \"flair_text\",\n            },\n            \"nsfw\": {\n                \"name\": _(\"NSFW\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"nsfw\",\n            },\n            \"ad\": {\n                \"name\": _(\"Is Ad?\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"advertisement\",\n            },\n            \"replies\": {\n                \"name\": _(\"Send Replies\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"sendreplies\",\n            },\n            \"spoiler\": {\n                \"name\": _(\"Is Spoiler\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"spoiler\",\n            },\n            \"resubmit\": {\n                \"name\": _(\"Resubmit Flag\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"resubmit\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        app_id=None,\n        app_secret=None,\n        targets=None,\n        kind=None,\n        nsfw=False,\n        sendreplies=True,\n        resubmit=False,\n        spoiler=False,\n        advertisement=False,\n        flair_id=None,\n        flair_text=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify Reddit Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Initialize subreddit list\n        self.subreddits = set()\n\n        # Not Safe For Work Flag\n        self.nsfw = nsfw\n\n        # Send Replies Flag\n        self.sendreplies = sendreplies\n\n        # Is Spoiler Flag\n        self.spoiler = spoiler\n\n        # Resubmit Flag\n        self.resubmit = resubmit\n\n        # Is Ad?\n        self.advertisement = advertisement\n\n        # Flair details\n        self.flair_id = flair_id\n        self.flair_text = flair_text\n\n        # Our keys we build using the provided content\n        self.__refresh_token = None\n        self.__access_token = None\n        self.__access_token_expiry = datetime.now(timezone.utc)\n\n        self.kind = (\n            kind.strip().lower()\n            if isinstance(kind, str)\n            else self.template_args[\"kind\"][\"default\"]\n        )\n\n        if self.kind not in REDDIT_MESSAGE_KINDS:\n            msg = f\"An invalid Reddit message kind ({kind}) was specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.user = validate_regex(self.user)\n        if not self.user:\n            msg = f\"An invalid Reddit User ID ({self.user}) was specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.password = validate_regex(self.password)\n        if not self.password:\n            msg = f\"An invalid Reddit Password ({self.password}) was specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.client_id = validate_regex(\n            app_id, *self.template_tokens[\"app_id\"][\"regex\"]\n        )\n        if not self.client_id:\n            msg = f\"An invalid Reddit App ID ({app_id}) was specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.client_secret = validate_regex(\n            app_secret, *self.template_tokens[\"app_secret\"][\"regex\"]\n        )\n        if not self.client_secret:\n            msg = f\"An invalid Reddit App Secret ({app_secret}) was specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Build list of subreddits\n        self.subreddits = [\n            sr.lstrip(\"#\") for sr in parse_list(targets) if sr.lstrip(\"#\")\n        ]\n\n        if not self.subreddits:\n            self.logger.warning(\"No subreddits were identified to be notified\")\n\n        # For Rate Limit Tracking Purposes\n        self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)\n\n        # Default to 1.0\n        self.ratelimit_remaining = 1.0\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.client_id,\n            self.client_secret,\n            self.user,\n            self.password,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"kind\": self.kind,\n            \"ad\": \"yes\" if self.advertisement else \"no\",\n            \"nsfw\": \"yes\" if self.nsfw else \"no\",\n            \"resubmit\": \"yes\" if self.resubmit else \"no\",\n            \"replies\": \"yes\" if self.sendreplies else \"no\",\n            \"spoiler\": \"yes\" if self.spoiler else \"no\",\n        }\n\n        # Flair support\n        if self.flair_id:\n            params[\"flair_id\"] = self.flair_id\n\n        if self.flair_text:\n            params[\"flair_text\"] = self.flair_text\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return (\n            \"{schema}://{user}:{password}@{app_id}/{app_secret}\"\n            \"/{targets}/?{params}\".format(\n                schema=self.secure_protocol,\n                user=NotifyReddit.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                app_id=self.pprint(\n                    self.client_id, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                app_secret=self.pprint(\n                    self.client_secret,\n                    privacy,\n                    mode=PrivacyMode.Secret,\n                    safe=\"\",\n                ),\n                targets=\"/\".join(\n                    [NotifyReddit.quote(x, safe=\"\") for x in self.subreddits]\n                ),\n                params=NotifyReddit.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.subreddits)\n\n    def login(self):\n        \"\"\"A simple wrapper to authenticate with the Reddit Server.\"\"\"\n\n        # Prepare our payload\n        payload = {\n            \"grant_type\": \"password\",\n            \"username\": self.user,\n            \"password\": self.password,\n        }\n\n        # Enforce a False flag setting before calling _fetch()\n        self.__access_token = False\n\n        # Send Login Information\n        postokay, response = self._fetch(\n            self.auth_url,\n            payload=payload,\n        )\n\n        if not postokay or not response:\n            # Setting this variable to False as a way of letting us know\n            # we failed to authenticate on our last attempt\n            self.__access_token = False\n            return False\n\n        # Our response object looks like this (content has been altered for\n        # presentation purposes):\n        # {\n        #     \"access_token\": Your access token,\n        #     \"token_type\": \"bearer\",\n        #     \"expires_in\": Unix Epoch Seconds,\n        #     \"scope\": A scope string,\n        #     \"refresh_token\": Your refresh token\n        # }\n\n        # Acquire our token\n        self.__access_token = response.get(\"access_token\")\n\n        # Handle other optional arguments we can use\n        if \"expires_in\" in response:\n            delta = timedelta(seconds=int(response[\"expires_in\"]))\n            self.__access_token_expiry = (\n                delta + datetime.now(timezone.utc) - self.clock_skew\n            )\n        else:\n            self.__access_token_expiry = (\n                self.access_token_lifetime_sec\n                + datetime.now(timezone.utc)\n                - self.clock_skew\n            )\n\n        # The Refresh Token\n        self.__refresh_token = response.get(\n            \"refresh_token\", self.__refresh_token\n        )\n\n        if self.__access_token:\n            self.logger.info(f\"Authenticated to Reddit as {self.user}\")\n            return True\n\n        self.logger.warning(f\"Failed to authenticate to Reddit as {self.user}\")\n\n        # Mark our failure\n        return False\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Reddit Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        if not self.__access_token and not self.login():\n            # We failed to authenticate - we're done\n            return False\n\n        if not len(self.subreddits):\n            # We have nothing to notify; we're done\n            self.logger.warning(\"There are no Reddit targets to notify\")\n            return False\n\n        # Prepare our Message Type/Kind\n        if self.kind == RedditMessageKind.AUTO:\n            parsed = NotifyBase.parse_url(body)\n            # Detect a link\n            if (\n                parsed\n                and parsed.get(\"schema\", \"\").startswith(\"http\")\n                and parsed.get(\"host\")\n            ):\n                kind = RedditMessageKind.LINK\n\n            else:\n                kind = RedditMessageKind.SELF\n        else:\n            kind = self.kind\n\n        # Create a copy of the subreddits list\n        subreddits = list(self.subreddits)\n        while len(subreddits) > 0:\n            # Retrieve our subreddit\n            subreddit = subreddits.pop()\n\n            # Prepare our payload\n            payload = {\n                \"ad\": bool(self.advertisement),\n                \"api_type\": \"json\",\n                \"extension\": \"json\",\n                \"sr\": subreddit,\n                \"title\": title if title else self.app_desc,\n                \"kind\": kind,\n                \"nsfw\": bool(self.nsfw),\n                \"resubmit\": bool(self.resubmit),\n                \"sendreplies\": bool(self.sendreplies),\n                \"spoiler\": bool(self.spoiler),\n            }\n\n            if self.flair_id:\n                payload[\"flair_id\"] = self.flair_id\n\n            if self.flair_text:\n                payload[\"flair_text\"] = self.flair_text\n\n            if kind == RedditMessageKind.LINK:\n                payload.update({\n                    \"url\": body,\n                })\n            else:\n                payload.update({\n                    \"text\": body,\n                })\n\n            postokay, _response = self._fetch(self.submit_url, payload=payload)\n            # only toggle has_error flag if we had an error\n            if not postokay:\n                # Mark our failure\n                has_error = True\n                continue\n\n            # If we reach here, we were successful\n            self.logger.info(f\"Sent Reddit notification to {subreddit}\")\n\n        return not has_error\n\n    def _fetch(self, url, payload=None):\n        \"\"\"Wrapper to Reddit API requests object.\"\"\"\n\n        # use what was specified, otherwise build headers dynamically\n        headers = {\"User-Agent\": f\"{__title__} v{__version__}\"}\n\n        if self.__access_token:\n            # Set our token\n            headers[\"Authorization\"] = f\"Bearer {self.__access_token}\"\n\n        # Prepare our url\n        url = self.submit_url if self.__access_token else self.auth_url\n\n        # Some Debug Logging\n        self.logger.debug(\n            f\"Reddit POST URL: {url} (cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"Reddit Payload: {payload!s}\")\n\n        # By default set wait to None\n        wait = None\n\n        if self.ratelimit_remaining <= 0.0:\n            # Determine how long we should wait for or if we should wait at\n            # all. This isn't fool-proof because we can't be sure the client\n            # time (calling this script) is completely synced up with the\n            # Reddit server.  One would hope we're on NTP and our clocks are\n            # the same allowing this to role smoothly:\n\n            now = datetime.now(timezone.utc).replace(tzinfo=None)\n            if now < self.ratelimit_reset:\n                # We need to throttle for the difference in seconds\n                wait = abs(\n                    (\n                        self.ratelimit_reset - now + self.clock_skew\n                    ).total_seconds()\n                )\n\n        # Always call throttle before any remote server i/o is made;\n        self.throttle(wait=wait)\n\n        # Initialize a default value for our content value\n        content = {}\n\n        # acquire our request mode\n        try:\n            r = requests.post(\n                url,\n                data=payload,\n                auth=(\n                    None\n                    if self.__access_token\n                    else (self.client_id, self.client_secret)\n                ),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            #  We attempt to login again and retry the original request\n            #  if we aren't in the process of handling a login already\n            if (\n                r.status_code != requests.codes.ok\n                and self.__access_token\n                and url != self.auth_url\n            ):\n\n                # We had a problem\n                status_str = NotifyReddit.http_response_code_lookup(\n                    r.status_code, REDDIT_HTTP_ERROR_MAP\n                )\n\n                self.logger.debug(\n                    \"Taking countermeasures after failed to send to Reddit \"\n                    \"{}: {}error={}\".format(\n                        url, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # We failed to authenticate with our token; login one more\n                # time and retry this original request\n                if not self.login():\n                    return (False, {})\n\n                # Try again\n                r = requests.post(\n                    url,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n            # Get our JSON content if it's possible\n            try:\n                content = loads(r.content)\n\n            except (TypeError, ValueError, AttributeError):\n                # TypeError = r.content is not a String\n                # ValueError = r.content is Unparsable\n                # AttributeError = r.content is None\n\n                # We had a problem\n                status_str = NotifyReddit.http_response_code_lookup(\n                    r.status_code, REDDIT_HTTP_ERROR_MAP\n                )\n\n                # Reddit always returns a JSON response\n                self.logger.warning(\n                    \"Failed to send to Reddit after countermeasures {}: \"\n                    \"{}error={}\".format(\n                        url, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n                return (False, {})\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyReddit.http_response_code_lookup(\n                    r.status_code, REDDIT_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send to Reddit {}: {}error={}\".format(\n                        url, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                # Mark our failure\n                return (False, content)\n\n            errors = (\n                []\n                if not content\n                else content.get(\"json\", {}).get(\"errors\", [])\n            )\n            if errors:\n                self.logger.warning(\n                    f\"Failed to send to Reddit {url}: {errors!s}\"\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                # Mark our failure\n                return (False, content)\n\n            try:\n                # Store our rate limiting (if provided)\n                self.ratelimit_remaining = float(\n                    r.headers.get(\"X-RateLimit-Remaining\")\n                )\n                self.ratelimit_reset = datetime.fromtimestamp(\n                    int(r.headers.get(\"X-RateLimit-Reset\")), timezone.utc\n                ).replace(tzinfo=None)\n\n            except (TypeError, ValueError):\n                # This is returned if we could not retrieve this information\n                # gracefully accept this state and move on\n                pass\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"Exception received when sending Reddit to {url}\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            return (False, content)\n\n        return (True, content)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Acquire our targets\n        results[\"targets\"] = NotifyReddit.split_path(results[\"fullpath\"])\n\n        # Kind override\n        if \"kind\" in results[\"qsd\"] and results[\"qsd\"][\"kind\"]:\n            results[\"kind\"] = NotifyReddit.unquote(\n                results[\"qsd\"][\"kind\"].strip().lower()\n            )\n        else:\n            results[\"kind\"] = RedditMessageKind.AUTO\n\n        # Is an Ad?\n        results[\"ad\"] = parse_bool(results[\"qsd\"].get(\"ad\", False))\n\n        # Get Not Safe For Work (NSFW) Flag\n        results[\"nsfw\"] = parse_bool(results[\"qsd\"].get(\"nsfw\", False))\n\n        # Send Replies Flag\n        results[\"replies\"] = parse_bool(results[\"qsd\"].get(\"replies\", True))\n\n        # Resubmit Flag\n        results[\"resubmit\"] = parse_bool(results[\"qsd\"].get(\"resubmit\", False))\n\n        # Is Spoiler Flag\n        results[\"spoiler\"] = parse_bool(results[\"qsd\"].get(\"spoiler\", False))\n\n        if \"flair_text\" in results[\"qsd\"]:\n            results[\"flair_text\"] = NotifyReddit.unquote(\n                results[\"qsd\"][\"flair_text\"]\n            )\n\n        if \"flair_id\" in results[\"qsd\"]:\n            results[\"flair_id\"] = NotifyReddit.unquote(\n                results[\"qsd\"][\"flair_id\"]\n            )\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyReddit.parse_list(results[\"qsd\"][\"to\"])\n\n        if \"app_id\" in results[\"qsd\"]:\n            results[\"app_id\"] = NotifyReddit.unquote(results[\"qsd\"][\"app_id\"])\n        else:\n            # The App/Bot ID is the hostname\n            results[\"app_id\"] = NotifyReddit.unquote(results[\"host\"])\n\n        if \"app_secret\" in results[\"qsd\"]:\n            results[\"app_secret\"] = NotifyReddit.unquote(\n                results[\"qsd\"][\"app_secret\"]\n            )\n        else:\n            # The first target identified is the App secret\n            results[\"app_secret\"] = (\n                None if not results[\"targets\"] else results[\"targets\"].pop(0)\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/resend.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# You will need an API Key for this plugin to work.\n# From the Settings -> API Keys you can click \"Create API Key\" if you don't\n# have one already. The key must have at least the \"Mail Send\" permission\n# to work.\n#\n# The schema to use the plugin looks like this:\n#    {schema}://{apikey}:{from_addr}\n#\n# Your {from_addr} must be comprissed of your Resend Authenticated\n# Domain.\n\n# Simple API Reference:\n#  - https://resend.com/onboarding\n\nfrom email.utils import formataddr\nfrom json import dumps\nimport logging\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_emails, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\nRESEND_HTTP_ERROR_MAP = {\n    200: \"Successful request.\",\n    400: \"Check that the parameters were correct.\",\n    401: \"The API key used was missing.\",\n    403: \"The API key used was invalid.\",\n    404: \"The resource was not found.\",\n    429: \"The rate limit was exceeded.\",\n}\n\n\nclass NotifyResend(NotifyBase):\n    \"\"\"A wrapper for Notify Resend Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Resend\"\n\n    # The services URL\n    service_url = \"https://resend.com\"\n\n    # The default secure protocol\n    secure_protocol = \"resend\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/resend/\"\n\n    # Default to markdown\n    notify_format = NotifyFormat.HTML\n\n    # The default Email API URL to use\n    notify_url = \"https://api.resend.com/emails\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.2\n\n    # The default subject to use if one isn't specified.\n    default_empty_subject = \"<no subject>\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}:{from_addr}\",\n        \"{schema}://{apikey}:{from_addr}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9._-]+$\", \"i\"),\n            },\n            \"from_addr\": {\n                \"name\": _(\"Source Email\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"map_to\": \"from_addr\",\n            },\n            \"name\": {\n                \"name\": _(\"From Name\"),\n                \"map_to\": \"from_addr\",\n            },\n            \"apikey\": {\n                \"map_to\": \"apikey\",\n            },\n            \"reply\": {\n                \"name\": _(\"Reply To\"),\n                \"type\": \"list:string\",\n                \"map_to\": \"reply_to\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    def __init__(\n        self, apikey, from_addr, targets=None, cc=None, bcc=None,\n        reply_to=None, **kwargs):\n        \"\"\"Initialize Notify Resend Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Resend API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire Targets (To Emails)\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # Acquire Reply To\n        self.reply_to = set()\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        result = is_email(from_addr)\n        if not result:\n            # Invalid from\n            msg = \"Invalid ~From~ email specified: {}\".format(from_addr)\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # initialize our from address\n        self.from_addr = (\n            result[\"name\"] if result[\"name\"] is not None else False,\n            result[\"full_email\"],\n        )\n\n        # Update our Name if specified\n        self.names[self.from_addr[1]] = (\n            result[\"name\"] if result[\"name\"] else False\n        )\n\n        # Acquire our targets\n        targets = parse_emails(targets)\n        if targets:\n            # Validate recipients (to:) and drop bad ones:\n            for recipient in targets:\n\n                result = is_email(recipient)\n                if result:\n                    self.targets.append(result[\"full_email\"])\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid email ({recipient}) specified.\",\n                )\n        else:\n            # If our target email list is empty we want to add ourselves to it\n            self.targets.append(self.from_addr[1])\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n\n            result = is_email(recipient)\n            if result:\n                self.cc.add(result[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[result[\"full_email\"]] = (\n                    result[\"name\"] if result[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n\n            result = is_email(recipient)\n            if result:\n                self.bcc.add(result[\"full_email\"])\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n        # Validate recipients (reply-to:) and drop bad ones:\n        for recipient in parse_emails(reply_to):\n            result = is_email(recipient)\n            if result:\n                self.reply_to.add(result[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[result[\"full_email\"]] = (\n                    result[\"name\"] if result[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Reply To email ({}) specified.\".format(\n                    recipient\n                ),\n            )\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey, self.from_addr)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if self.cc:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for it's escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\",\n                ).replace(\",\", \"%2C\")\n                for e in self.cc\n            ])\n\n        if len(self.bcc) > 0:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join(self.bcc)\n\n        if self.reply_to:\n            # Handle our Reply-To Addresses\n            params[\"reply\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for its escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\",\n                ).replace(\",\", \"%2C\")\n                for e in self.reply_to\n            ])\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0] == self.from_addr[1])\n\n        if self.from_addr[0] and self.from_addr[0] != self.app_id:\n            # A custom name was provided\n            params[\"name\"] = self.from_addr[0]\n\n        return \"{schema}://{apikey}:{from_addr}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            # never encode email since it plays a huge role in our hostname\n            from_addr=self.from_addr[1],\n            targets=(\n                \"\"\n                if not has_targets\n                else \"/\".join(\n                    [NotifyResend.quote(x, safe=\"@\") for x in self.targets]\n                )\n            ),\n            params=NotifyResend.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Resend Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.apikey}\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our from_name\n        self.from_addr[0] \\\n            if self.from_addr[0] is not False else self.app_id\n\n        payload_ = {\n            \"from\": formataddr(self.from_addr, charset=\"utf-8\"),\n            # A subject is a requirement, so if none is specified we must\n            # set a default with at least 1 character or Resend will deny\n            # our request\n            \"subject\": title if title else self.default_empty_subject,\n            (\n                \"text\" if self.notify_format == NotifyFormat.TEXT else \"html\"\n            ): body,\n        }\n\n        if attach and self.attachment_support:\n            attachments = []\n\n            # Send our attachments\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Resend attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    attachments.append({\n                        \"content\": attachment.base64(),\n                        \"filename\": (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                        \"type\": \"application/octet-stream\",\n                        \"disposition\": \"attachment\",\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Resend attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending Resend attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n            # Append our attachments to the payload\n            payload_.update({\n                \"attachments\": attachments,\n            })\n\n        targets = list(self.targets)\n        while len(targets) > 0:\n            target = targets.pop(0)\n\n            # Create a copy of our template\n            payload = payload_.copy()\n\n            # unique cc/bcc list management\n            cc = self.cc - self.bcc - {target}\n            bcc = self.bcc - {target}\n\n            # handle our reply to\n            reply_to = self.reply_to - {target}\n\n            # Format our cc addresses to support the Name field\n            cc = [\n                formataddr((self.names.get(addr, False), addr),\n                           charset=\"utf-8\")\n                for addr in cc\n            ]\n\n            # Format our reply-to addresses to support the Name field\n            reply_to = [\n                formataddr((self.names.get(addr, False), addr),\n                           charset=\"utf-8\")\n                for addr in reply_to\n            ]\n\n            # Set our target\n            payload[\"to\"] = target\n\n            if cc:\n                payload[\"cc\"] = cc\n\n            if len(bcc):\n                payload[\"bcc\"] = list(bcc)\n\n            if reply_to:\n                payload[\"reply_to\"] = reply_to\n\n            # Some Debug Logging\n            if self.logger.isEnabledFor(logging.DEBUG):\n                # Due to attachments; output can be quite heavy and io\n                # intensive.\n                # To accommodate this, we only show our debug payload\n                # information if required.\n                self.logger.debug(\n                    \"Resend POST URL:\"\n                    f\" {self.notify_url} \"\n                    f\"(cert_verify={self.verify_certificate!r})\"\n                )\n                self.logger.debug(\n                    \"Resend Payload: %s\", sanitize_payload(payload))\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.accepted,\n                ):\n                    # We had a problem\n                    status_str = NotifyResend.http_response_code_lookup(\n                        r.status_code, RESEND_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Resend notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Resend notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Resend \"\n                    f\"notification to {target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Our URL looks like this:\n        #    {schema}://{apikey}:{from_addr}/{targets}\n        #\n        # which actually equates to:\n        #    {schema}://{user}:{password}@{host}/{email1}/{email2}/etc..\n        #                 ^       ^         ^\n        #                 |       |         |\n        #              apikey     -from addr-\n\n        # Prepare our API Key\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            results[\"apikey\"] = \\\n                NotifyResend.unquote(results[\"qsd\"][\"apikey\"])\n\n        else:\n            results[\"apikey\"] = NotifyResend.unquote(results[\"user\"])\n\n        # Our Targets\n        results[\"targets\"] = []\n\n        # Attempt to detect 'from' email address\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"from_addr\"] = NotifyResend.unquote(results[\"qsd\"][\"from\"])\n\n            if results.get(\"host\"):\n                results[\"targets\"].append(\n                    NotifyResend.unquote(results[\"host\"]))\n\n        else:\n            # Prepare our From Email Address\n            results[\"from_addr\"] = \"{}@{}\".format(\n                NotifyResend.unquote(\n                    results[\"password\"]\n                    if results[\"password\"] else results[\"user\"]),\n                NotifyResend.unquote(results[\"host\"]),\n            )\n\n        if \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n            results[\"from_addr\"] = formataddr((\n                NotifyResend.unquote(results[\"qsd\"][\"name\"]),\n                results[\"from_addr\"]), charset=\"utf-8\")\n\n        # Acquire our targets\n        results[\"targets\"].extend(NotifyResend.split_path(results[\"fullpath\"]))\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyResend.parse_list(results[\"qsd\"][\"to\"])\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = NotifyResend.parse_list(results[\"qsd\"][\"cc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = NotifyResend.parse_list(results[\"qsd\"][\"bcc\"])\n\n        # Handle Reply To Addresses\n        if \"reply\" in results[\"qsd\"] and len(results[\"qsd\"][\"reply\"]):\n            results[\"reply_to\"] = results[\"qsd\"][\"reply\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/revolt.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Youll need your own Revolt Bot and a Channel Id for the notifications to\n# be sent in since Revolt does not support webhooks yet.\n#\n#  This plugin will simply work using the url of:\n#     revolt://BOT_TOKEN/CHANNEL_ID\n#\n# API Documentation:\n#    - https://api.revolt.chat/swagger/index.html\n#\n\nfrom datetime import datetime, timedelta, timezone\nfrom json import dumps, loads\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyRevolt(NotifyBase):\n    \"\"\"A wrapper for Revolt Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Revolt\"\n\n    # The services URL\n    service_url = \"https://revolt.chat/\"\n\n    # The default secure protocol\n    secure_protocol = \"revolt\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/revolt/\"\n\n    # Revolt Channel Message\n    notify_url = \"https://api.revolt.chat/\"\n\n    # Revolt supports attachments but doesn't support it here (for now)\n    attachment_support = False\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_256\n\n    # Revolt is kind enough to return how many more requests we're allowed to\n    # continue to make within it's header response as:\n    # X-RateLimit-Reset: The epoc time (in seconds) we can expect our\n    #                    rate-limit to be reset.\n    # X-RateLimit-Remaining: an integer identifying how many requests we're\n    #                        still allow to make.\n    request_rate_per_sec = 3\n\n    # Safety net\n    clock_skew = timedelta(seconds=2)\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 2000\n\n    # Title Maximum Length\n    title_maxlen = 100\n\n    # Define object templates\n    templates = (\"{schema}://{bot_token}/{targets}\",)\n\n    # Defile out template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"bot_token\": {\n                \"name\": _(\"Bot Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_channel\": {\n                \"name\": _(\"Channel ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n                \"private\": True,\n                \"required\": True,\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"channel\": {\n                \"alias_of\": \"targets\",\n            },\n            \"bot_token\": {\n                \"alias_of\": \"bot_token\",\n            },\n            \"icon_url\": {\"name\": _(\"Icon URL\"), \"type\": \"string\"},\n            \"url\": {\n                \"name\": _(\"Embed URL\"),\n                \"type\": \"string\",\n                \"map_to\": \"link\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(self, bot_token, targets, icon_url=None, link=None, **kwargs):\n        super().__init__(**kwargs)\n\n        # Bot Token\n        self.bot_token = validate_regex(bot_token)\n        if not self.bot_token:\n            msg = f\"An invalid Revolt Bot Token ({bot_token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Parse our Channel IDs\n        self.targets = []\n        for target in parse_list(targets):\n            results = validate_regex(\n                target, *self.template_tokens[\"target_channel\"][\"regex\"]\n            )\n\n            if not results:\n                self.logger.warning(\n                    f\"Dropped invalid Revolt channel ({target}) specified.\",\n                )\n                continue\n\n            # Add our target\n            self.targets.append(target)\n\n        # Image for Embed\n        self.icon_url = icon_url\n\n        # Url for embed title\n        self.link = link\n\n        # For Tracking Purposes\n        self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)\n\n        # Default to 1.0\n        self.ratelimit_remaining = 1.0\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Revolt Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            self.logger.warning(\"There were not Revolt channels to notify.\")\n            return False\n\n        payload = {}\n\n        # Acquire image_url\n        image_url = (\n            self.icon_url if self.icon_url else self.image_url(notify_type)\n        )\n\n        if self.notify_format == NotifyFormat.MARKDOWN:\n            payload[\"embeds\"] = [{\n                \"title\": None if not title else title[0 : self.title_maxlen],\n                \"description\": body,\n                # Our color associated with our notification\n                \"colour\": self.color(notify_type),\n                \"replies\": None,\n            }]\n\n            if image_url:\n                payload[\"embeds\"][0][\"icon_url\"] = image_url\n\n            if self.link:\n                payload[\"embeds\"][0][\"url\"] = self.link\n\n        else:\n            payload[\"content\"] = body if not title else f\"{title}\\n{body}\"\n\n        has_error = False\n        channel_ids = list(self.targets)\n        for channel_id in channel_ids:\n            postokay, _response = self._send(payload, channel_id)\n            if not postokay:\n                # Failed to send message\n                has_error = True\n\n        return not has_error\n\n    def _send(self, payload, channel_id, retries=1, **kwargs):\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"X-Bot-Token\": self.bot_token,\n            \"Content-Type\": \"application/json; charset=utf-8\",\n            \"Accept\": \"application/json; charset=utf-8\",\n        }\n\n        notify_url = f\"{self.notify_url}channels/{channel_id}/messages\"\n\n        self.logger.debug(\n            \"Revolt POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Revolt Payload: {payload!s}\")\n\n        # By default set wait to None\n        wait = None\n\n        now = datetime.now(timezone.utc).replace(tzinfo=None)\n        if self.ratelimit_remaining <= 0.0 and now < self.ratelimit_reset:\n            # Determine how long we should wait for or if we should wait at\n            # all. This isn't fool-proof because we can't be sure the client\n            # time (calling this script) is completely synced up with the\n            # Discord server.  One would hope we're on NTP and our clocks are\n            # the same allowing this to role smoothly and set our throttle\n            # accordingly\n            wait = abs(\n                (\n                    self.ratelimit_reset - now + self.clock_skew\n                ).total_seconds()\n            )\n\n        # Default content response object\n        content = {}\n\n        # Always call throttle before any remote server i/o is made;\n        self.throttle(wait=wait)\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                content = loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                content = {}\n\n            # Handle rate limiting (if specified)\n            try:\n                # Store our rate limiting (if provided)\n                self.ratelimit_remaining = int(\n                    r.headers.get(\"X-RateLimit-Remaining\")\n                )\n                self.ratelimit_reset = now + timedelta(\n                    seconds=(\n                        int(r.headers.get(\"X-RateLimit-Reset-After\")) / 1000\n                    )\n                )\n\n            except (TypeError, ValueError):\n                # This is returned if we could not retrieve this\n                # information gracefully accept this state and move on\n                pass\n\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n\n                # Some details to debug by\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\",\n                    content if content else (r.content or b\"\")[:2000])\n\n                # We had a problem\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Revolt request limit reached; \"\n                    \"instructed to throttle for %.3fs\",\n                    abs(\n                        (\n                            self.ratelimit_reset - now + self.clock_skew\n                        ).total_seconds()\n                    ),\n                )\n\n                if (\n                    r.status_code == requests.codes.too_many_requests\n                    and retries > 0\n                ):\n\n                    # Try again\n                    return self._send(\n                        payload=payload,\n                        channel_id=channel_id,\n                        retries=retries - 1,\n                        **kwargs,\n                    )\n\n                self.logger.warning(\n                    \"Failed to send to Revolt notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                # Return; we're done\n                return (False, content)\n\n            else:\n                self.logger.info(\"Sent Revolt notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred posting to Revolt.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return (False, content)\n\n        return (True, content)\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.bot_token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {}\n\n        if self.icon_url:\n            params[\"icon_url\"] = self.icon_url\n\n        if self.link:\n            params[\"url\"] = self.link\n\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{bot_token}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            bot_token=self.pprint(self.bot_token, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [self.pprint(x, privacy, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyRevolt.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return 1 if not self.targets else len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Store our bot token\n        bot_token = NotifyRevolt.unquote(results[\"host\"])\n\n        # Now fetch the Channel IDs\n        targets = NotifyRevolt.split_path(results[\"fullpath\"])\n\n        results[\"bot_token\"] = bot_token\n        results[\"targets\"] = targets\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyRevolt.parse_list(results[\"qsd\"][\"to\"])\n\n        # Support channel id on the URL string (if specified)\n        if \"channel\" in results[\"qsd\"]:\n            results[\"targets\"] += NotifyRevolt.parse_list(\n                results[\"qsd\"][\"channel\"]\n            )\n\n        # Support bot token on the URL string (if specified)\n        if \"bot_token\" in results[\"qsd\"]:\n            results[\"bot_token\"] = NotifyRevolt.unquote(\n                results[\"qsd\"][\"bot_token\"]\n            )\n\n        if \"icon_url\" in results[\"qsd\"]:\n            results[\"icon_url\"] = NotifyRevolt.unquote(\n                results[\"qsd\"][\"icon_url\"]\n            )\n\n        if \"url\" in results[\"qsd\"]:\n            results[\"link\"] = NotifyRevolt.unquote(results[\"qsd\"][\"url\"])\n\n        if \"format\" not in results[\"qsd\"] and (\n            \"url\" in results or \"icon_url\" in results\n        ):\n            # Markdown is implied\n            results[\"format\"] = NotifyFormat.MARKDOWN\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/rocketchat.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom itertools import chain\nfrom json import dumps, loads\nimport re\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, parse_list\nfrom .base import NotifyBase\n\nIS_CHANNEL = re.compile(r\"^#(?P<name>[A-Za-z0-9_-]+)$\")\nIS_USER = re.compile(r\"^@(?P<name>[A-Za-z0-9._-]+)$\")\nIS_ROOM_ID = re.compile(r\"^(?P<name>[A-Za-z0-9]+)$\")\n\n# Extend HTTP Error Messages\nRC_HTTP_ERROR_MAP = {\n    400: \"Channel/RoomId is wrong format, or missing from server.\",\n    401: \"Authentication tokens provided is invalid or missing.\",\n}\n\n\nclass RocketChatAuthMode:\n    \"\"\"The Chat Authentication mode is detected.\"\"\"\n\n    # providing a webhook\n    WEBHOOK = \"webhook\"\n\n    # Support token submission\n    TOKEN = \"token\"\n\n    # Providing a username and password (default)\n    BASIC = \"basic\"\n\n\n# Define our authentication modes\nROCKETCHAT_AUTH_MODES = (\n    RocketChatAuthMode.WEBHOOK,\n    RocketChatAuthMode.TOKEN,\n    RocketChatAuthMode.BASIC,\n)\n\n\nclass NotifyRocketChat(NotifyBase):\n    \"\"\"A wrapper for Notify Rocket.Chat Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Rocket.Chat\"\n\n    # The services URL\n    service_url = \"https://rocket.chat/\"\n\n    # The default protocol\n    protocol = \"rocket\"\n\n    # The default secure protocol\n    secure_protocol = \"rockets\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/rocketchat/\"\n\n    # Allows the user to specify the NotifyImageSize object; this is supported\n    # through the webhook\n    image_size = NotifyImageSize.XY_128\n\n    # The title is not used\n    title_maxlen = 0\n\n    # The maximum size of the message\n    body_maxlen = 1000\n\n    # Default to markdown\n    notify_format = NotifyFormat.MARKDOWN\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{token}@{host}:{port}/{targets}\",\n        \"{schema}://{user}:{token}@{host}/{targets}\",\n        \"{schema}://{webhook}@{host}\",\n        \"{schema}://{webhook}@{host}:{port}\",\n        \"{schema}://{webhook}@{host}/{targets}\",\n        \"{schema}://{webhook}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"token\": {\n                \"name\": _(\"API Token\"),\n                \"map_to\": \"password\",\n                \"private\": True,\n            },\n            \"webhook\": {\n                \"name\": _(\"Webhook\"),\n                \"type\": \"string\",\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"target_room\": {\n                \"name\": _(\"Target Room ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"mode\": {\n                \"name\": _(\"Webhook Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": ROCKETCHAT_AUTH_MODES,\n            },\n            \"avatar\": {\n                \"name\": _(\"Use Avatar\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"webhook\": {\n                \"alias_of\": \"webhook\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self, webhook=None, targets=None, mode=None, avatar=None, **kwargs\n    ):\n        \"\"\"Initialize Notify Rocket.Chat Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Set our schema\n        self.schema = \"https\" if self.secure else \"http\"\n\n        # Prepare our URL\n        self.api_url = f\"{self.schema}://{self.host}\"\n\n        if isinstance(self.port, int):\n            self.api_url += f\":{self.port}\"\n\n        # Initialize channels list\n        self.channels = []\n\n        # Initialize room list\n        self.rooms = []\n\n        # Initialize user list (webhook only)\n        self.users = []\n\n        # Assign our webhook (if defined)\n        self.webhook = webhook\n\n        # Used to track token headers upon authentication (if successful)\n        # This is only used if not on webhook mode\n        self.headers = {}\n\n        # Authentication mode\n        self.mode = None if not isinstance(mode, str) else mode.lower()\n\n        if self.mode and self.mode not in ROCKETCHAT_AUTH_MODES:\n            msg = f\"The authentication mode specified ({mode}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Detect our mode if it wasn't specified\n        if not self.mode:\n            if self.webhook is not None:\n                # Just a username was specified, we treat this as a webhook\n                self.mode = RocketChatAuthMode.WEBHOOK\n            elif self.password and len(self.password) > 32:\n                self.mode = RocketChatAuthMode.TOKEN\n            else:\n                self.mode = RocketChatAuthMode.BASIC\n\n            self.logger.debug(\n                \"Auto-Detected Rocketchat Auth Mode: %s\", self.mode\n            )\n\n        if self.mode in (\n            RocketChatAuthMode.BASIC,\n            RocketChatAuthMode.TOKEN,\n        ) and not (self.user and self.password):\n            # Username & Password is required for Rocket Chat to work\n            msg = \"No Rocket.Chat {} was specified.\".format(\n                \"user/pass combo\"\n                if self.mode == RocketChatAuthMode.BASIC\n                else \"user/apikey\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        elif self.mode == RocketChatAuthMode.WEBHOOK and not self.webhook:\n            msg = \"No Rocket.Chat Incoming Webhook was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if self.mode == RocketChatAuthMode.TOKEN:\n            # Set our headers for further communication\n            self.headers.update({\n                \"X-User-Id\": self.user,\n                \"X-Auth-Token\": self.password,\n            })\n\n        # Validate recipients and drop bad ones:\n        for recipient in parse_list(targets):\n            result = IS_CHANNEL.match(recipient)\n            if result:\n                # store valid device\n                self.channels.append(result.group(\"name\"))\n                continue\n\n            result = IS_ROOM_ID.match(recipient)\n            if result:\n                # store valid room\n                self.rooms.append(result.group(\"name\"))\n                continue\n\n            result = IS_USER.match(recipient)\n            if result:\n                # store valid room\n                self.users.append(result.group(\"name\"))\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid channel/room/user ({recipient}) specified.\",\n            )\n\n        if (\n            self.mode == RocketChatAuthMode.BASIC\n            and len(self.rooms) == 0\n            and len(self.channels) == 0\n        ):\n            msg = \"No Rocket.Chat room and/or channels specified to notify.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prepare our avatar setting\n        # - if specified; that trumps all\n        # - if not specified and we're dealing with a basic setup, the Avatar\n        #   is disabled by default. This is because if the account doesn't\n        #   have the bot flag set on it it won't work as documented here:\n        #       https://developer.rocket.chat/api/rest-api/endpoints\\\n        #             /team-collaboration-endpoints/chat/postmessage\n        # - Otherwise if we're a webhook, we enable the avatar by default\n        #   (if not otherwise specified) since it will work nicely.\n        # Place an avatar image to associate with our content\n        if self.mode == RocketChatAuthMode.BASIC:\n            self.avatar = False if avatar is None else avatar\n\n        else:  # self.mode == RocketChatAuthMode.WEBHOOK:\n            self.avatar = True if avatar is None else avatar\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.host,\n            self.port if self.port else (443 if self.secure else 80),\n            self.user,\n            (\n                self.password\n                if self.mode\n                in (RocketChatAuthMode.BASIC, RocketChatAuthMode.TOKEN)\n                else self.webhook\n            ),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"avatar\": \"yes\" if self.avatar else \"no\",\n            \"mode\": self.mode,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        if self.mode in (RocketChatAuthMode.BASIC, RocketChatAuthMode.TOKEN):\n            auth = \"{user}:{password}@\".format(\n                user=NotifyRocketChat.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n\n        else:\n            auth = \"{user}{webhook}@\".format(\n                user=(\n                    \"{}:\".format(NotifyRocketChat.quote(self.user, safe=\"\"))\n                    if self.user\n                    else \"\"\n                ),\n                webhook=self.pprint(\n                    self.webhook, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}/{targets}/?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            targets=\"/\".join([\n                NotifyRocketChat.quote(x, safe=\"@#\")\n                for x in chain(\n                    # Channels are prefixed with a pound/hashtag symbol\n                    [f\"#{x}\" for x in self.channels],\n                    # Rooms are as is\n                    self.rooms,\n                    # Users\n                    [f\"@{x}\" for x in self.users],\n                )\n            ]),\n            params=NotifyRocketChat.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.channels) + len(self.rooms) + len(self.users)\n        return targets if targets > 0 else 1\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Wrapper to _send since we can alert more then one channel.\"\"\"\n\n        # Call the _send_ function applicable to whatever mode we're in\n        # - calls _send_webhook_notification if the mode variable is set\n        # - calls _send_basic_notification if the mode variable is not set\n        return getattr(\n            self,\n            \"_send_{}_notification\".format(\n                RocketChatAuthMode.WEBHOOK\n                if self.mode == RocketChatAuthMode.WEBHOOK\n                else RocketChatAuthMode.BASIC\n            ),\n        )(body=body, title=title, notify_type=notify_type, **kwargs)\n\n    def _send_webhook_notification(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"Sends a webhook notification.\"\"\"\n\n        # Our payload object\n        payload = self._payload(body, title, notify_type)\n\n        # Assemble our webhook URL\n        path = f\"hooks/{self.webhook}\"\n\n        # Build our list of channels/rooms/users (if any identified)\n        targets = [f\"@{u}\" for u in self.users]\n        targets.extend([f\"#{c}\" for c in self.channels])\n        targets.extend([f\"{r}\" for r in self.rooms])\n\n        if len(targets) == 0:\n            # We can take an early exit\n            return self._send(\n                payload, notify_type=notify_type, path=path, **kwargs\n            )\n\n        # Otherwise we want to iterate over each of the targets\n\n        # Initiaize our error tracking\n        has_error = False\n\n        while len(targets):\n            # Retrieve our target\n            target = targets.pop(0)\n\n            # Assign our channel/room/user\n            payload[\"channel\"] = target\n\n            if not self._send(\n                payload, notify_type=notify_type, path=path, **kwargs\n            ):\n\n                # toggle flag\n                has_error = True\n\n        return not has_error\n\n    def _send_basic_notification(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"Authenticates with the server using a user/pass combo for\n        notifications.\"\"\"\n        # Track whether we authenticated okay\n\n        if self.mode == RocketChatAuthMode.BASIC and not self.login():\n            return False\n\n        # prepare JSON Object\n        payload_ = self._payload(body, title, notify_type)\n\n        # Initiaize our error tracking\n        has_error = False\n\n        # Build our list of channels/rooms/users (if any identified)\n        channels = [f\"@{u}\" for u in self.users]\n        channels.extend([f\"#{c}\" for c in self.channels])\n\n        # Create a copy of our channels to notify against\n        payload = payload_.copy()\n        while len(channels) > 0:\n            # Get Channel\n            channel = channels.pop(0)\n            payload[\"channel\"] = channel\n\n            if not self._send(payload, notify_type=notify_type, **kwargs):\n\n                # toggle flag\n                has_error = True\n\n        # Create a copy of our room id's to notify against\n        rooms = list(self.rooms)\n        payload = payload_.copy()\n        while len(rooms):\n            # Get Room\n            room = rooms.pop(0)\n            payload[\"roomId\"] = room\n\n            if not self._send(payload, notify_type=notify_type, **kwargs):\n\n                # toggle flag\n                has_error = True\n\n        if self.mode == RocketChatAuthMode.BASIC:\n            # logout\n            self.logout()\n\n        return not has_error\n\n    def _payload(self, body, title=\"\", notify_type=NotifyType.INFO):\n        \"\"\"Prepares a payload object.\"\"\"\n        # prepare JSON Object\n        payload = {\n            \"text\": body,\n        }\n\n        # apply our images if they're set to be displayed\n        image_url = self.image_url(notify_type)\n        if self.avatar and image_url:\n            payload[\"avatar\"] = image_url\n\n        return payload\n\n    def _send(\n        self, payload, notify_type, path=\"api/v1/chat.postMessage\", **kwargs\n    ):\n        \"\"\"Perform Notify Rocket.Chat Notification.\"\"\"\n\n        api_url = f\"{self.api_url}/{path}\"\n\n        self.logger.debug(\n            \"Rocket.Chat POST URL:\"\n            f\" {api_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Rocket.Chat Payload: {payload!s}\")\n\n        # Copy our existing headers\n        headers = self.headers.copy()\n\n        # Apply minimum headers\n        headers.update({\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        })\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                api_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyRocketChat.http_response_code_lookup(\n                    r.status_code, RC_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send Rocket.Chat {}:notification: \"\n                    \"{}{}error={}.\".format(\n                        self.mode,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(f\"Sent Rocket.Chat {self.mode}:notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Rocket.Chat \"\n                f\"{self.mode}:notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    def login(self):\n        \"\"\"Login to our server.\"\"\"\n\n        payload = {\n            \"username\": self.user,\n            \"password\": self.password,\n        }\n\n        api_url = \"{}/{}\".format(self.api_url, \"api/v1/login\")\n\n        try:\n            r = requests.post(\n                api_url,\n                data=payload,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyRocketChat.http_response_code_lookup(\n                    r.status_code, RC_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to authenticate {} with Rocket.Chat: \"\n                    \"{}{}error={}.\".format(\n                        self.user,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.debug(\"Rocket.Chat authentication successful\")\n                response = loads(r.content)\n                if response.get(\"status\") != \"success\":\n                    self.logger.warning(\n                        f\"Could not authenticate {self.user} with Rocket.Chat.\"\n                    )\n                    return False\n\n                # Set our headers for further communication\n                self.headers[\"X-Auth-Token\"] = response.get(\n                    \"data\", {\"authToken\": None}\n                ).get(\"authToken\")\n                self.headers[\"X-User-Id\"] = response.get(\n                    \"data\", {\"userId\": None}\n                ).get(\"userId\")\n\n        except (AttributeError, TypeError, ValueError):\n            # Our response was not the JSON type we had expected it to be\n            # - ValueError = r.content is Unparsable\n            # - TypeError = r.content is None\n            # - AttributeError = r is None\n            self.logger.warning(\n                f\"A commuication error occurred authenticating {self.user} on \"\n                \"Rocket.Chat.\"\n            )\n            return False\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"A connection error occurred authenticating {self.user} on \"\n                \"Rocket.Chat.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        return True\n\n    def logout(self):\n        \"\"\"Logout of our server.\"\"\"\n\n        api_url = \"{}/{}\".format(self.api_url, \"api/v1/logout\")\n\n        try:\n            r = requests.post(\n                api_url,\n                headers=self.headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyRocketChat.http_response_code_lookup(\n                    r.status_code, RC_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to logoff {} from Rocket.Chat: \"\n                    \"{}{}error={}.\".format(\n                        self.user,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.debug(\n                    f\"Rocket.Chat log off successful; response {r.content}.\"\n                )\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred logging off the \"\n                \"Rocket.Chat server\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        try:\n            # Attempt to detect the webhook (if specified in the URL)\n            # If no webhook is specified, then we just pass along as if nothing\n            # happened. However if we do find a webhook, we want to rebuild our\n            # URL without it since it conflicts with standard URLs. Support\n            # %2F since that is a forward slash escaped\n\n            # rocket://webhook@host\n            # rocket://user:webhook@host\n            match = re.match(\n                r\"^\\s*(?P<schema>[^:]+://)((?P<user>[^:]+):)?\"\n                r\"(?P<webhook>[a-z0-9]+(/|%2F)\"\n                r\"[a-z0-9]+)\\@(?P<url>.+)$\",\n                url,\n                re.I,\n            )\n\n        except TypeError:\n            # Not a string\n            return None\n\n        if match:\n            # Re-assemble our URL without the webhook\n            url = \"{schema}{user}{url}\".format(\n                schema=match.group(\"schema\"),\n                user=(\n                    \"{}@\".format(match.group(\"user\"))\n                    if match.group(\"user\")\n                    else \"\"\n                ),\n                url=match.group(\"url\"),\n            )\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if match:\n            # store our webhook\n            results[\"webhook\"] = NotifyRocketChat.unquote(\n                match.group(\"webhook\")\n            )\n\n            # Take on the password too in the event we're in basic mode\n            # We do not unquote() as this is done at a later state\n            results[\"password\"] = match.group(\"webhook\")\n\n        # Apply our targets\n        results[\"targets\"] = NotifyRocketChat.split_path(results[\"fullpath\"])\n\n        # The user may have forced the mode\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            results[\"mode\"] = NotifyRocketChat.unquote(results[\"qsd\"][\"mode\"])\n\n        # avatar icon\n        if \"avatar\" in results[\"qsd\"] and len(results[\"qsd\"][\"avatar\"]):\n            results[\"avatar\"] = parse_bool(results[\"qsd\"].get(\"avatar\", True))\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyRocketChat.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # The 'webhook' over-ride (if specified)\n        if \"webhook\" in results[\"qsd\"] and len(results[\"qsd\"][\"webhook\"]):\n            results[\"webhook\"] = NotifyRocketChat.unquote(\n                results[\"qsd\"][\"webhook\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/rsyslog.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport os\nimport socket\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n\nclass syslog:\n    \"\"\"Extrapoloated information from the syslog library so that this plugin\n    would not be dependent on it.\"\"\"\n\n    # Notification Categories\n    LOG_KERN = 0\n    LOG_USER = 8\n    LOG_MAIL = 16\n    LOG_DAEMON = 24\n    LOG_AUTH = 32\n    LOG_SYSLOG = 40\n    LOG_LPR = 48\n    LOG_NEWS = 56\n    LOG_UUCP = 64\n    LOG_CRON = 72\n    LOG_LOCAL0 = 128\n    LOG_LOCAL1 = 136\n    LOG_LOCAL2 = 144\n    LOG_LOCAL3 = 152\n    LOG_LOCAL4 = 160\n    LOG_LOCAL5 = 168\n    LOG_LOCAL6 = 176\n    LOG_LOCAL7 = 184\n\n    # Notification Types\n    LOG_INFO = 6\n    LOG_NOTICE = 5\n    LOG_WARNING = 4\n    LOG_CRIT = 2\n\n\nclass SyslogFacility:\n    \"\"\"All of the supported facilities.\"\"\"\n\n    KERN = \"kern\"\n    USER = \"user\"\n    MAIL = \"mail\"\n    DAEMON = \"daemon\"\n    AUTH = \"auth\"\n    SYSLOG = \"syslog\"\n    LPR = \"lpr\"\n    NEWS = \"news\"\n    UUCP = \"uucp\"\n    CRON = \"cron\"\n    LOCAL0 = \"local0\"\n    LOCAL1 = \"local1\"\n    LOCAL2 = \"local2\"\n    LOCAL3 = \"local3\"\n    LOCAL4 = \"local4\"\n    LOCAL5 = \"local5\"\n    LOCAL6 = \"local6\"\n    LOCAL7 = \"local7\"\n\n\nSYSLOG_FACILITY_MAP = {\n    SyslogFacility.KERN: syslog.LOG_KERN,\n    SyslogFacility.USER: syslog.LOG_USER,\n    SyslogFacility.MAIL: syslog.LOG_MAIL,\n    SyslogFacility.DAEMON: syslog.LOG_DAEMON,\n    SyslogFacility.AUTH: syslog.LOG_AUTH,\n    SyslogFacility.SYSLOG: syslog.LOG_SYSLOG,\n    SyslogFacility.LPR: syslog.LOG_LPR,\n    SyslogFacility.NEWS: syslog.LOG_NEWS,\n    SyslogFacility.UUCP: syslog.LOG_UUCP,\n    SyslogFacility.CRON: syslog.LOG_CRON,\n    SyslogFacility.LOCAL0: syslog.LOG_LOCAL0,\n    SyslogFacility.LOCAL1: syslog.LOG_LOCAL1,\n    SyslogFacility.LOCAL2: syslog.LOG_LOCAL2,\n    SyslogFacility.LOCAL3: syslog.LOG_LOCAL3,\n    SyslogFacility.LOCAL4: syslog.LOG_LOCAL4,\n    SyslogFacility.LOCAL5: syslog.LOG_LOCAL5,\n    SyslogFacility.LOCAL6: syslog.LOG_LOCAL6,\n    SyslogFacility.LOCAL7: syslog.LOG_LOCAL7,\n}\n\nSYSLOG_FACILITY_RMAP = {\n    syslog.LOG_KERN: SyslogFacility.KERN,\n    syslog.LOG_USER: SyslogFacility.USER,\n    syslog.LOG_MAIL: SyslogFacility.MAIL,\n    syslog.LOG_DAEMON: SyslogFacility.DAEMON,\n    syslog.LOG_AUTH: SyslogFacility.AUTH,\n    syslog.LOG_SYSLOG: SyslogFacility.SYSLOG,\n    syslog.LOG_LPR: SyslogFacility.LPR,\n    syslog.LOG_NEWS: SyslogFacility.NEWS,\n    syslog.LOG_UUCP: SyslogFacility.UUCP,\n    syslog.LOG_CRON: SyslogFacility.CRON,\n    syslog.LOG_LOCAL0: SyslogFacility.LOCAL0,\n    syslog.LOG_LOCAL1: SyslogFacility.LOCAL1,\n    syslog.LOG_LOCAL2: SyslogFacility.LOCAL2,\n    syslog.LOG_LOCAL3: SyslogFacility.LOCAL3,\n    syslog.LOG_LOCAL4: SyslogFacility.LOCAL4,\n    syslog.LOG_LOCAL5: SyslogFacility.LOCAL5,\n    syslog.LOG_LOCAL6: SyslogFacility.LOCAL6,\n    syslog.LOG_LOCAL7: SyslogFacility.LOCAL7,\n}\n\n# Used as a lookup when handling the Apprise -> Syslog Mapping\nSYSLOG_PUBLISH_MAP = {\n    NotifyType.INFO: syslog.LOG_INFO,\n    NotifyType.SUCCESS: syslog.LOG_NOTICE,\n    NotifyType.FAILURE: syslog.LOG_CRIT,\n    NotifyType.WARNING: syslog.LOG_WARNING,\n}\n\n\nclass NotifyRSyslog(NotifyBase):\n    \"\"\"A wrapper for Remote Syslog Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Remote Syslog\"\n\n    # The services URL\n    service_url = \"https://tools.ietf.org/html/rfc5424\"\n\n    # The default protocol\n    protocol = \"rsyslog\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/rsyslog/\"\n\n    # Disable throttle rate for RSyslog requests\n    request_rate_per_sec = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}\",\n        \"{schema}://{host}:{port}\",\n        \"{schema}://{host}/{facility}\",\n        \"{schema}://{host}:{port}/{facility}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"facility\": {\n                \"name\": _(\"Facility\"),\n                \"type\": \"choice:string\",\n                \"values\": list(SYSLOG_FACILITY_MAP),\n                \"default\": SyslogFacility.USER,\n                \"required\": True,\n            },\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n                \"default\": 514,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"facility\": {\n                # We map back to the same element defined in template_tokens\n                \"alias_of\": \"facility\",\n            },\n            \"logpid\": {\n                \"name\": _(\"Log PID\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"log_pid\",\n            },\n        },\n    )\n\n    def __init__(self, facility=None, log_pid=True, **kwargs):\n        \"\"\"Initialize RSyslog Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if facility:\n            try:\n                self.facility = SYSLOG_FACILITY_MAP[facility]\n\n            except KeyError:\n                msg = f\"An invalid syslog facility ({facility}) was specified.\"\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n        else:\n            self.facility = SYSLOG_FACILITY_MAP[\n                self.template_tokens[\"facility\"][\"default\"]\n            ]\n\n        # Include PID with each message.\n        self.log_pid = log_pid\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform RSyslog Notification.\"\"\"\n\n        if title:\n            # Format title\n            body = f\"{title}: {body}\"\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        host = self.host\n        port = (\n            self.port if self.port else self.template_tokens[\"port\"][\"default\"]\n        )\n\n        priority = SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8\n        payload = f\"<{priority}>- {os.getpid()} {body}\" \\\n            if self.log_pid else f\"<{priority}>- {body}\"\n\n        # send UDP packet to upstream server\n        self.logger.debug(\n            \"RSyslog Host: %s:%d/%s\",\n            host,\n            port,\n            SYSLOG_FACILITY_RMAP[self.facility],\n        )\n        self.logger.debug(f\"RSyslog Payload: {payload!s}\")\n\n        # our sent bytes\n        sent = 0\n\n        try:\n            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n            sock.settimeout(self.socket_connect_timeout)\n            sent = sock.sendto(payload.encode(\"utf-8\"), (host, port))\n            sock.close()\n\n        except socket.gaierror as e:\n            self.logger.warning(\n                \"A connection error occurred sending RSyslog \"\n                \"notification to %s:%d/%s\",\n                host,\n                port,\n                SYSLOG_FACILITY_RMAP[self.facility],\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        except socket.timeout as e:\n            self.logger.warning(\n                \"A connection timeout occurred sending RSyslog \"\n                \"notification to %s:%d/%s\",\n                host,\n                port,\n                SYSLOG_FACILITY_RMAP[self.facility],\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        if sent < len(payload):\n            self.logger.warning(\n                \"RSyslog sent %d byte(s) but intended to send %d byte(s)\",\n                sent,\n                len(payload),\n            )\n            return False\n\n        self.logger.info(\"Sent RSyslog notification.\")\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.protocol,\n            self.host,\n            (\n                self.port\n                if self.port\n                else self.template_tokens[\"port\"][\"default\"]\n            ),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"logpid\": \"yes\" if self.log_pid else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{hostname}{port}/{facility}/?{params}\".format(\n            schema=self.protocol,\n            hostname=NotifyRSyslog.quote(self.host, safe=\"\"),\n            port=(\n                \"\"\n                if self.port is None\n                or self.port == self.template_tokens[\"port\"][\"default\"]\n                else f\":{self.port}\"\n            ),\n            facility=(\n                self.template_tokens[\"facility\"][\"default\"]\n                if self.facility not in SYSLOG_FACILITY_RMAP\n                else SYSLOG_FACILITY_RMAP[self.facility]\n            ),\n            params=NotifyRSyslog.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        tokens = []\n\n        # Get our path values\n        tokens.extend(NotifyRSyslog.split_path(results[\"fullpath\"]))\n\n        # Initialization\n        facility = None\n\n        if tokens:\n            # Store the last entry as the facility\n            facility = tokens[-1].lower()\n\n        # However if specified on the URL, that will over-ride what was\n        # identified\n        if \"facility\" in results[\"qsd\"] and len(results[\"qsd\"][\"facility\"]):\n            facility = results[\"qsd\"][\"facility\"].lower()\n\n        if facility and facility not in SYSLOG_FACILITY_MAP:\n            # Find first match; if no match is found we set the result\n            # to the matching key.  This allows us to throw a TypeError\n            # during the __init__() call. The benifit of doing this\n            # check here is if we do have a valid match, we can support\n            # short form matches like 'u' which will match against user\n            facility = next(\n                (f for f in SYSLOG_FACILITY_MAP if f.startswith(facility)),\n                facility,\n            )\n\n        # Save facility if set\n        if facility:\n            results[\"facility\"] = facility\n\n        # Include PID as part of the message logged\n        results[\"log_pid\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"logpid\", NotifyRSyslog.template_args[\"logpid\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/ryver.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you need to first generate a webhook.\n\n# When you're complete, you will recieve a URL that looks something like this:\n#                https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG\n#                          ^                                        ^\n#                          |                                        |\n#  These are important <---^----------------------------------------^\n#\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, validate_regex\nfrom .base import NotifyBase\n\n\nclass RyverWebhookMode:\n    \"\"\"Ryver supports to webhook modes.\"\"\"\n\n    SLACK = \"slack\"\n    RYVER = \"ryver\"\n\n\n# Define the types in a list for validation purposes\nRYVER_WEBHOOK_MODES = (\n    RyverWebhookMode.SLACK,\n    RyverWebhookMode.RYVER,\n)\n\n\nclass NotifyRyver(NotifyBase):\n    \"\"\"A wrapper for Ryver Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Ryver\"\n\n    # The services URL\n    service_url = \"https://ryver.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"ryver\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/ryver/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1000\n\n    # Define object templates\n    templates = (\n        \"{schema}://{organization}/{token}\",\n        \"{schema}://{botname}@{organization}/{token}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"organization\": {\n                \"name\": _(\"Organization\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9_-]{3,32}$\", \"i\"),\n            },\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[A-Z0-9]{15}$\", \"i\"),\n            },\n            \"botname\": {\n                \"name\": _(\"Bot Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"user\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"mode\": {\n                \"name\": _(\"Webhook Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": RYVER_WEBHOOK_MODES,\n                \"default\": RyverWebhookMode.RYVER,\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        organization,\n        token,\n        mode=RyverWebhookMode.RYVER,\n        include_image=True,\n        **kwargs,\n    ):\n        \"\"\"Initialize Ryver Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Token (associated with project)\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"An invalid Ryver API Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Organization (associated with project)\n        self.organization = validate_regex(\n            organization, *self.template_tokens[\"organization\"][\"regex\"]\n        )\n        if not self.organization:\n            msg = (\n                \"An invalid Ryver Organization \"\n                f\"({organization}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our webhook mode\n        self.mode = None if not isinstance(mode, str) else mode.lower()\n\n        if self.mode not in RYVER_WEBHOOK_MODES:\n            msg = f\"The Ryver webhook mode specified ({mode}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Place an image inline with the message body\n        self.include_image = include_image\n\n        # Slack formatting requirements are defined here which Ryver supports:\n        # https://api.slack.com/docs/message-formatting\n        self._re_formatting_map = {\n            # New lines must become the string version\n            r\"\\r\\*\\n\": \"\\\\n\",\n            # Escape other special characters\n            r\"&\": \"&amp;\",\n            r\"<\": \"&lt;\",\n            r\">\": \"&gt;\",\n        }\n\n        # Iterate over above list and store content accordingly\n        self._re_formatting_rules = re.compile(\n            r\"(\" + \"|\".join(self._re_formatting_map.keys()) + r\")\",\n            re.IGNORECASE,\n        )\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Ryver Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        if self.mode == RyverWebhookMode.SLACK:\n            # Perform Slack formatting\n            title = self._re_formatting_rules.sub(  # pragma: no branch\n                lambda x: self._re_formatting_map[x.group()],\n                title,\n            )\n            body = self._re_formatting_rules.sub(  # pragma: no branch\n                lambda x: self._re_formatting_map[x.group()],\n                body,\n            )\n\n        url = f\"https://{self.organization}.ryver.com/application/webhook/{self.token}\"\n\n        # prepare JSON Object\n        payload = {\n            \"body\": body if not title else f\"**{title}**\\r\\n{body}\",\n            \"createSource\": {\n                \"displayName\": self.user,\n                \"avatar\": None,\n            },\n        }\n\n        # Acquire our image url if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        if image_url:\n            payload[\"createSource\"][\"avatar\"] = image_url\n\n        self.logger.debug(\n            f\"Ryver POST URL: {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Ryver Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyBase.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Ryver notification: {}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Ryver notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending\"\n                f\" Ryver:{self.organization} \"\n                + \"notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.organization, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"mode\": self.mode,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine if there is a botname present\n        botname = \"\"\n        if self.user:\n            botname = \"{botname}@\".format(\n                botname=NotifyRyver.quote(self.user, safe=\"\"),\n            )\n\n        return \"{schema}://{botname}{organization}/{token}/?{params}\".format(\n            schema=self.secure_protocol,\n            botname=botname,\n            organization=NotifyRyver.quote(self.organization, safe=\"\"),\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            params=NotifyRyver.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The first token is stored in the hostname\n        results[\"organization\"] = NotifyRyver.unquote(results[\"host\"])\n\n        # Now fetch the remaining tokens\n        try:\n            results[\"token\"] = NotifyRyver.split_path(results[\"fullpath\"])[0]\n\n        except IndexError:\n            # no token\n            results[\"token\"] = None\n\n        # Retrieve the mode\n        results[\"mode\"] = results[\"qsd\"].get(\"mode\", RyverWebhookMode.RYVER)\n\n        # use image= for consistency with the other plugins\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://RYVER_ORG.ryver.com/application/webhook/TOKEN\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://(?P<org>[A-Z0-9_-]+)\\.ryver\\.com/application/webhook/\"\n            r\"(?P<webhook_token>[A-Z0-9]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyRyver.parse_url(\n                \"{schema}://{org}/{webhook_token}/{params}\".format(\n                    schema=NotifyRyver.secure_protocol,\n                    org=result.group(\"org\"),\n                    webhook_token=result.group(\"webhook_token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/sendgrid.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# You will need an API Key for this plugin to work.\n# From the Settings -> API Keys you can click \"Create API Key\" if you don't\n# have one already. The key must have at least the \"Mail Send\" permission\n# to work.\n#\n# The schema to use the plugin looks like this:\n#    {schema}://{apikey}:{from_email}\n#\n# Your {from_email} must be comprissed of your Sendgrid Authenticated\n# Domain. The same domain must have 'Link Branding' turned on as well or it\n# will not work. This can be seen from Settings -> Sender Authentication.\n\n# If you're (SendGrid) verified domain is example.com, then your schema may\n# look something like this:\n\n# Simple API Reference:\n#  - https://sendgrid.com/docs/API_Reference/Web_API_v3/index.html\n#  - https://sendgrid.com/docs/ui/sending-email/\\\n#       how-to-send-an-email-with-dynamic-transactional-templates/\n\nfrom json import dumps\nimport logging\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_list, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nSENDGRID_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - You do not have authorization to make the request.\",\n    413: (\n        \"Payload To Large - The JSON payload you have included in your \"\n        \"request is too large.\"\n    ),\n    429: (\n        \"Too Many Requests - The number of requests you have made exceeds \"\n        \"SendGrid's rate limitations.\"\n    ),\n}\n\n\nclass NotifySendGrid(NotifyBase):\n    \"\"\"A wrapper for Notify SendGrid Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SendGrid\"\n\n    # The services URL\n    service_url = \"https://sendgrid.com\"\n\n    # The default secure protocol\n    secure_protocol = \"sendgrid\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/sendgrid/\"\n\n    # Default to markdown\n    notify_format = NotifyFormat.HTML\n\n    # The default Email API URL to use\n    notify_url = \"https://api.sendgrid.com/v3/mail/send\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.2\n\n    # The default subject to use if one isn't specified.\n    default_empty_subject = \"<no subject>\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}:{from_email}\",\n        \"{schema}://{apikey}:{from_email}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9._-]+$\", \"i\"),\n            },\n            \"from_email\": {\n                \"name\": _(\"Source Email\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"template\": {\n                # Template ID\n                # The template ID is 64 characters with one dash (d-uuid)\n                \"name\": _(\"Template\"),\n                \"type\": \"string\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Support Template Dynamic Variables (Substitutions)\n    template_kwargs = {\n        \"template_data\": {\n            \"name\": _(\"Template Data\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self,\n        apikey,\n        from_email,\n        targets=None,\n        cc=None,\n        bcc=None,\n        template=None,\n        template_data=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify SendGrid Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid SendGrid API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_email(from_email)\n        if not result:\n            msg = f\"Invalid ~From~ email specified: {from_email}\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store email address\n        self.from_email = result[\"full_email\"]\n\n        # Acquire Targets (To Emails)\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # Now our dynamic template (if defined)\n        self.template = template\n\n        # Now our dynamic template data (if defined)\n        self.template_data = (\n            template_data if isinstance(template_data, dict) else {}\n        )\n\n        # Validate recipients (to:) and drop bad ones:\n        if targets:\n            for recipient in parse_list(targets):\n\n                result = is_email(recipient)\n                if result:\n                    self.targets.append(result[\"full_email\"])\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid email ({recipient}) specified.\",\n                )\n        else:\n            # add ourselves\n            self.targets.append(self.from_email)\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_list(cc):\n\n            result = is_email(recipient)\n            if result:\n                self.cc.add(result[\"full_email\"])\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_list(bcc):\n\n            result = is_email(recipient)\n            if result:\n                self.bcc.add(result[\"full_email\"])\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey, self.from_email)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if len(self.cc) > 0:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join(self.cc)\n\n        if len(self.bcc) > 0:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join(self.bcc)\n\n        if self.template:\n            # Handle our Template ID if if was specified\n            params[\"template\"] = self.template\n\n        # Append our template_data into our parameter list\n        params.update({f\"+{k}\": v for k, v in self.template_data.items()})\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0] == self.from_email\n        )\n\n        return \"{schema}://{apikey}:{from_email}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            # never encode email since it plays a huge role in our hostname\n            from_email=self.from_email,\n            targets=(\n                \"\"\n                if not has_targets\n                else \"/\".join(\n                    [NotifySendGrid.quote(x, safe=\"\") for x in self.targets]\n                )\n            ),\n            params=NotifySendGrid.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return max(1, len(self.targets))\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform SendGrid Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\n                \"There are no SendGrid email recipients to notify\")\n            return False\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.apikey}\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # A Simple Email Payload Template\n        payload_ = {\n            \"personalizations\": [{\n                # Placeholder\n                \"to\": [{\"email\": None}],\n            }],\n            \"from\": {\n                \"email\": self.from_email,\n            },\n            # A subject is a requirement, so if none is specified we must\n            # set a default with at least 1 character or SendGrid will deny\n            # our request\n            \"subject\": title if title else self.default_empty_subject,\n            \"content\": [{\n                \"type\": (\n                    \"text/plain\"\n                    if self.notify_format == NotifyFormat.TEXT\n                    else \"text/html\"\n                ),\n                \"value\": body,\n            }],\n        }\n\n        if attach and self.attachment_support:\n            attachments = []\n\n            # Send our attachments\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SendGrid attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    attachments.append({\n                        \"content\": attachment.base64(),\n                        \"filename\": (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                        \"type\": \"application/octet-stream\",\n                        \"disposition\": \"attachment\",\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SendGrid attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending SendGrid attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n            # Append our attachments to the payload\n            payload_.update({\n                \"attachments\": attachments,\n            })\n\n        if self.template:\n            payload_[\"template_id\"] = self.template\n\n            if self.template_data:\n                payload_[\"personalizations\"][0][\"dynamic_template_data\"] = (\n                    dict(self.template_data.items())\n                )\n\n        targets = list(self.targets)\n        while len(targets) > 0:\n            target = targets.pop(0)\n\n            # Create a copy of our template\n            payload = payload_.copy()\n\n            # the cc, bcc, to field must be unique or SendMail will fail, the\n            # below code prepares this by ensuring the target isn't in the cc\n            # list or bcc list. It also makes sure the cc list does not contain\n            # any of the bcc entries\n            cc = self.cc - self.bcc - {target}\n            bcc = self.bcc - {target}\n\n            # Set our target\n            payload[\"personalizations\"][0][\"to\"][0][\"email\"] = target\n\n            if len(cc):\n                payload[\"personalizations\"][0][\"cc\"] = [\n                    {\"email\": email} for email in cc\n                ]\n\n            if len(bcc):\n                payload[\"personalizations\"][0][\"bcc\"] = [\n                    {\"email\": email} for email in bcc\n                ]\n\n            # Some Debug Logging\n            if self.logger.isEnabledFor(logging.DEBUG):\n                # Due to attachments; output can be quite heavy and io\n                # intensive.\n                # To accommodate this, we only show our debug payload\n                # information if required.\n                self.logger.debug(\n                    \"SendGrid POST URL:\"\n                    f\" {self.notify_url} \"\n                    f\"(cert_verify={self.verify_certificate!r})\"\n                )\n                self.logger.debug(\n                    \"SendGrid Payload: %s\", sanitize_payload(payload))\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.accepted,\n                ):\n                    # We had a problem\n                    status_str = NotifySendGrid.http_response_code_lookup(\n                        r.status_code, SENDGRID_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send SendGrid notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent SendGrid notification to {target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending SendGrid \"\n                    f\"notification to {target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Our URL looks like this:\n        #    {schema}://{apikey}:{from_email}/{targets}\n        #\n        # which actually equates to:\n        #    {schema}://{user}:{password}@{host}/{email1}/{email2}/etc..\n        #                 ^       ^         ^\n        #                 |       |         |\n        #              apikey     -from addr-\n\n        if not results.get(\"user\"):\n            # An API Key as not properly specified\n            return None\n\n        if not results.get(\"password\"):\n            # A From Email was not correctly specified\n            return None\n\n        # Prepare our API Key\n        results[\"apikey\"] = NotifySendGrid.unquote(results[\"user\"])\n\n        # Prepare our From Email Address\n        results[\"from_email\"] = \"{}@{}\".format(\n            NotifySendGrid.unquote(results[\"password\"]),\n            NotifySendGrid.unquote(results[\"host\"]),\n        )\n\n        # Acquire our targets\n        results[\"targets\"] = NotifySendGrid.split_path(results[\"fullpath\"])\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySendGrid.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = NotifySendGrid.parse_list(results[\"qsd\"][\"cc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = NotifySendGrid.parse_list(results[\"qsd\"][\"bcc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"template\" in results[\"qsd\"] and len(results[\"qsd\"][\"template\"]):\n            results[\"template\"] = NotifySendGrid.unquote(\n                results[\"qsd\"][\"template\"]\n            )\n\n        # Add any template substitutions\n        results[\"template_data\"] = results[\"qsd+\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/sendpulse.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Simple API Reference:\n#  - https://sendpulse.com/integrations/api/smtp\n\nimport base64\nfrom email.utils import formataddr\nfrom json import dumps, loads\nimport logging\nimport re\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyFormat, NotifyType, PersistentStoreMode\nfrom ..conversion import convert_between\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_emails, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n\nclass NotifySendPulse(NotifyBase):\n    \"\"\"\n    A wrapper for Notify SendPulse Notifications\n    \"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SendPulse\"\n\n    # The services URL\n    service_url = \"https://sendpulse.com\"\n\n    # The default secure protocol\n    secure_protocol = \"sendpulse\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/sendpulse/\"\n\n    # Default to markdown\n    notify_format = NotifyFormat.HTML\n\n    # The default Email API URL to use\n    notify_email_url = \"https://api.sendpulse.com/smtp/emails\"\n\n    # Our OAuth Query\n    notify_oauth_url = \"https://api.sendpulse.com/oauth/access_token\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.2\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference\n    storage_mode = PersistentStoreMode.AUTO\n\n    # Token expiry if not detected in seconds (below is 1 hr)\n    token_expiry = 3600\n\n    # The number of seconds to grace for early token renewal\n    # Below states that 10 seconds bfore our token expiry, we'll\n    # attempt to renew it\n    token_expiry_edge = 10\n\n    # Support attachments\n    attachment_support = True\n\n    # The default subject to use if one isn't specified.\n    default_empty_subject = \"<no subject>\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}@{host}/{client_secret}/\",\n        \"{schema}://{user}@{host}/{client_id}/{client_secret}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(NotifyBase.template_tokens, **{\n        \"user\": {\n            \"name\": _(\"User Name\"),\n            \"type\": \"string\",\n            \"required\": True,\n        },\n        \"host\": {\n            \"name\": _(\"Domain\"),\n            \"type\": \"string\",\n            \"required\": True,\n        },\n        \"client_id\": {\n            \"name\": _(\"Client ID\"),\n            \"type\": \"string\",\n            \"required\": True,\n            \"private\": True,\n            \"regex\": (r\"^[A-Z0-9._-]+$\", \"i\"),\n        },\n        \"client_secret\": {\n            \"name\": _(\"Client Secret\"),\n            \"type\": \"string\",\n            \"required\": True,\n            \"private\": True,\n            \"regex\": (r\"^[A-Z0-9._-]+$\", \"i\"),\n        },\n        \"target_email\": {\n            \"name\": _(\"Target Email\"),\n            \"type\": \"string\",\n            \"map_to\": \"targets\",\n        },\n        \"targets\": {\n            \"name\": _(\"Targets\"),\n            \"type\": \"list:string\",\n        },\n    })\n\n    # Define our template arguments\n    template_args = dict(NotifyBase.template_args, **{\n        \"from\": {\n            \"name\": _(\"From Email\"),\n            \"type\": \"string\",\n            \"map_to\": \"from_addr\",\n        },\n        \"template\": {\n            # The template ID is an integer\n            \"name\": _(\"Template ID\"),\n            \"type\": \"int\",\n        },\n        \"id\": {\n            \"alias_of\": \"client_id\",\n        },\n        \"secret\": {\n            \"alias_of\": \"client_secret\",\n        },\n        \"to\": {\n            \"alias_of\": \"targets\",\n        },\n        \"cc\": {\n            \"name\": _(\"Carbon Copy\"),\n            \"type\": \"list:string\",\n        },\n        \"bcc\": {\n            \"name\": _(\"Blind Carbon Copy\"),\n            \"type\": \"list:string\",\n        },\n    })\n\n    # Support Template Dynamic Variables (Substitutions)\n    template_kwargs = {\n        \"template_data\": {\n            \"name\": _(\"Template Data\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(self, client_id, client_secret, from_addr=None, targets=None,\n                 cc=None, bcc=None, template=None, template_data=None,\n                 **kwargs):\n        \"\"\"\n        Initialize Notify SendPulse Object\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        # Temporary from_addr to work with for parsing\n        from_addr_ = [self.app_id, \"\"]\n\n        if self.user:\n            if self.host:\n                # Prepare the bases of our email\n                from_addr_ = [from_addr_[0], \"{}@{}\".format(\n                    re.split(r\"[\\s@]+\", self.user)[0],\n                    self.host,\n                )]\n\n            else:\n                result = is_email(self.user)\n                if result:\n                    # Prepare the bases of our email and include domain\n                    self.host = result[\"domain\"]\n                    from_addr_ = [\n                        result[\"name\"] if result[\"name\"]\n                        else from_addr_[0], self.user]\n\n        if isinstance(from_addr, str):\n            result = is_email(from_addr)\n            if result:\n                from_addr_ = (\n                    result[\"name\"] if result[\"name\"] else from_addr_[0],\n                    result[\"full_email\"])\n            else:\n                # Only update the string but use the already detected info\n                from_addr_[0] = from_addr\n\n        result = is_email(from_addr_[1])\n        if not result:\n            # Parse Source domain based on from_addr\n            msg = \"Invalid ~From~ email specified: {}\".format(\n                \"{} <{}>\".format(from_addr_[0], from_addr_[1])\n                if from_addr_[0] else \"{}\".format(from_addr_[1]))\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our lookup\n        self.from_addr = from_addr_[1]\n        self.names[from_addr_[1]] = from_addr_[0]\n\n        # Client ID\n        self.client_id = validate_regex(\n            client_id, *self.template_tokens[\"client_id\"][\"regex\"])\n        if not self.client_id:\n            msg = \"An invalid SendPulse Client ID \" \\\n                  \"({}) was specified.\".format(client_id)\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Client Secret\n        self.client_secret = validate_regex(\n            client_secret, *self.template_tokens[\"client_secret\"][\"regex\"])\n        if not self.client_secret:\n            msg = \"An invalid SendPulse Client Secret \" \\\n                  \"({}) was specified.\".format(client_secret)\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire Targets (To Emails)\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # No template\n        self.template = None\n        if template:\n            try:\n                # Store our template\n                self.template = int(template)\n\n            except (TypeError, ValueError):\n                # Not a valid integer; ignore entry\n                err = \"The SendPulse Template ID specified ({}) is invalid.\"\\\n                    .format(template)\n                self.logger.warning(err)\n                raise TypeError(err) from None\n\n        # Now our dynamic template data (if defined)\n        self.template_data = template_data \\\n            if isinstance(template_data, dict) else {}\n\n        if targets:\n            # Validate recipients (to:) and drop bad ones:\n            for recipient in parse_emails(targets):\n                result = is_email(recipient)\n                if result:\n                    self.targets.append(result[\"full_email\"])\n                    if result[\"name\"]:\n                        self.names[result[\"full_email\"]] = result[\"name\"]\n                    continue\n\n                self.logger.warning(\n                    \"Dropped invalid To email \"\n                    \"({}) specified.\".format(recipient),\n                )\n\n        else:\n            # If our target email list is empty we want to add ourselves to it\n            self.targets.append(self.from_addr)\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n\n            result = is_email(recipient)\n            if result:\n                self.cc.add(result[\"full_email\"])\n                if result[\"name\"]:\n                    self.names[result[\"full_email\"]] = result[\"name\"]\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Carbon Copy email \"\n                \"({}) specified.\".format(recipient),\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n\n            result = is_email(recipient)\n            if result:\n                self.bcc.add(result[\"full_email\"])\n                if result[\"name\"]:\n                    self.names[result[\"full_email\"]] = result[\"name\"]\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                \"({}) specified.\".format(recipient),\n            )\n\n        if len(self.targets) == 0:\n            # Notify ourselves\n            self.targets.append(self.from_addr)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"\n        Returns all of the identifiers that make this URL unique from\n        another simliar one. Targets or end points should never be identified\n        here.\n        \"\"\"\n        return (self.secure_protocol, self.client_id, self.client_secret)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"\n        Returns the URL built dynamically based on specified arguments.\n        \"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if len(self.cc) > 0:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for it's escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\").replace(\",\", \"%2C\")\n                for e in self.cc])\n\n        if len(self.bcc) > 0:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join([\n                formataddr(\n                    (self.names.get(e, False), e),\n                    # Swap comma for it's escaped url code (if detected) since\n                    # we're using that as a delimiter\n                    charset=\"utf-8\").replace(\",\", \"%2C\")\n                for e in self.bcc])\n\n        if self.template:\n            # Handle our Template ID if if was specified\n            params[\"template\"] = self.template\n\n        # handle from=\n        if self.names[self.from_addr] != self.app_id:\n            params[\"from\"] = self.names[self.from_addr]\n\n        # Append our template_data into our parameter list\n        params.update(\n            {\"+{}\".format(k): v for k, v in self.template_data.items()})\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = \\\n            not (len(self.targets) == 1 and self.targets[0] == self.from_addr)\n\n        return \"{schema}://{source}/{cid}/{secret}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            source=self.from_addr,\n            cid=self.pprint(self.client_id, privacy, safe=\"\"),\n            secret=self.pprint(self.client_secret, privacy, safe=\"\"),\n            targets=\"\" if not has_targets else \"/\".join(\n                [NotifySendPulse.quote(x, safe=\"\") for x in self.targets]),\n            params=NotifySendPulse.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"\n        Returns the number of targets associated with this notification\n        \"\"\"\n        return len(self.targets)\n\n    def login(self):\n        \"\"\"\n        Authenticates with the server to get a access_token\n        \"\"\"\n        self.store.clear(\"access_token\")\n        payload = {\n            \"grant_type\": \"client_credentials\",\n            \"client_id\": self.client_id,\n            \"client_secret\": self.client_secret,\n        }\n\n        success, response = self._fetch(self.notify_oauth_url, payload)\n        if not success:\n            return False\n\n        access_token = response.get(\"access_token\")\n\n        # If we get here, we're authenticated\n        try:\n            expires = \\\n                int(response.get(\"expires_in\")) - self.token_expiry_edge\n            if expires <= self.token_expiry_edge:\n                self.logger.error(\n                    \"SendPulse token expiry limit returned was invalid\")\n                return False\n\n            elif expires > self.token_expiry:\n                self.logger.warning(\n                    \"SendPulse token expiry limit fixed to: {}s\"\n                    .format(self.token_expiry))\n                expires = self.token_expiry - self.token_expiry_edge\n\n        except (AttributeError, TypeError, ValueError):\n            # expires_in was not an integer\n            self.logger.warning(\n                \"SendPulse token expiry limit presumed to be: {}s\".format(\n                    self.token_expiry))\n            expires = self.token_expiry - self.token_expiry_edge\n\n        self.store.set(\"access_token\", access_token, expires=expires)\n\n        return access_token\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, attach=None,\n             **kwargs):\n        \"\"\"\n        Perform SendPulse Notification\n        \"\"\"\n\n        access_token = self.store.get(\"access_token\") or self.login()\n        if not access_token:\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # A Simple Email Payload Template\n        payload_ = {\n            \"email\": {\n                \"from\": {\n                    \"name\": self.names[self.from_addr],\n                    \"email\": self.from_addr,\n                },\n                # To is populated further on\n                \"to\": [],\n                # A subject is a requirement, so if none is specified we must\n                # set a default with at least 1 character or SendPulse will\n                # deny our request\n                \"subject\": title if title else self.default_empty_subject,\n            }\n        }\n\n        # Prepare Email Message\n        if self.notify_format == NotifyFormat.HTML:\n            # HTML\n            payload_[\"email\"].update({\n                \"text\": convert_between(\n                    NotifyFormat.HTML, NotifyFormat.TEXT, body),\n                \"html\": base64.b64encode(body.encode(\"utf-8\")).decode(\"ascii\"),\n            })\n\n        else:  # Text\n            payload_[\"email\"][\"text\"] = body\n\n        if attach and self.attachment_support:\n            attachments = {}\n\n            # Send our attachments\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SendPulse attachment {}.\".format(\n                            attachment.url(privacy=True)))\n                    return False\n\n                try:\n                    attachments[\n                        attachment.name if attachment.name\n                        else f\"file{no:03}.dat\"] = attachment.base64()\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SendPulse attachment {}.\".format(\n                            attachment.url(privacy=True)))\n                    return False\n\n                self.logger.debug(\n                    \"Appending SendPulse attachment {}\".format(\n                        attachment.url(privacy=True)))\n\n            # Append our attachments to the payload\n            payload_[\"email\"].update({\n                \"attachments_binary\": attachments,\n            })\n\n        if self.template:\n            payload_[\"email\"].update({\n                \"template\": {\n                    \"id\": self.template,\n                    \"variables\": self.template_data,\n                }})\n\n        targets = list(self.targets)\n        while len(targets) > 0:\n            target = targets.pop(0)\n\n            # Create a copy of our template\n            payload = payload_.copy()\n\n            # the cc, bcc, to field must be unique or SendMail will fail, the\n            # below code prepares this by ensuring the target isn't in the cc\n            # list or bcc list. It also makes sure the cc list does not contain\n            # any of the bcc entries\n            cc = (self.cc - self.bcc - {target})\n            bcc = (self.bcc - {target})\n\n            #\n            # prepare our 'to'\n            #\n            to = {\n                \"email\": target\n            }\n            if target in self.names:\n                to[\"name\"] = self.names[target]\n\n            # Set our target\n            payload[\"email\"][\"to\"] = [to]\n\n            if len(cc):\n                payload[\"email\"][\"cc\"] = []\n                for email in cc:\n                    item = {\n                        \"email\": email,\n                    }\n                if email in self.names:\n                    item[\"name\"] = self.names[email]\n\n                payload[\"email\"][\"cc\"].append(item)\n\n            if len(bcc):\n                payload[\"email\"][\"bcc\"] = []\n                for email in bcc:\n                    item = {\n                        \"email\": email,\n                    }\n                if email in self.names:\n                    item[\"name\"] = self.names[email]\n\n                payload[\"email\"][\"bcc\"].append(item)\n\n            # Perform our post\n            success, _response = self._fetch(\n                self.notify_email_url, payload, target, retry=1)\n            if not success:\n                has_error = True\n                continue\n\n        return not has_error\n\n    def _fetch(self, url, payload, target=None, retry=0):\n        \"\"\"\n        Wrapper to request.post() to manage it's response better and make\n        the send() function cleaner and easier to maintain.\n\n        This function returns True if the _post was successful and False\n        if it wasn't.\n        \"\"\"\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        access_token = self.store.get(\"access_token\")\n        if access_token:\n            headers.update({\"Authorization\": f\"Bearer {access_token}\"})\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                f\"SendPulse POST URL: {url}\"\n                f\"(cert_verify={self.verify_certificate!r})\")\n            self.logger.debug(\n                \"SendPulse Payload: %s\", sanitize_payload(payload))\n\n        # Prepare our default response\n        response = {}\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                response = loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # This gets thrown if we can't parse our JSON Response\n                #  - ValueError = r.content is Unparsable\n                #  - TypeError = r.content is None\n                #  - AttributeError = r is None\n                self.logger.warning(\"Invalid response from SendPulse server.\")\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n                return (False, {})\n\n            # Reference status code\n            status_code = r.status_code\n\n            # Key likely expired, we'll reset it and try one more time\n            if status_code == requests.codes.unauthorized \\\n                    and retry and self.login():\n                return self._fetch(url, payload, target, retry=retry - 1)\n\n            if status_code not in (\n                    requests.codes.ok, requests.codes.accepted):\n                # We had a problem\n                status_str = \\\n                    NotifySendPulse.http_response_code_lookup(\n                        status_code)\n\n                if target:\n                    self.logger.warning(\n                        \"Failed to send SendPulse notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code))\n                else:\n                    self.logger.warning(\n                        \"SendPulse Authentication Request failed: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code))\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n            else:\n                if target:\n                    self.logger.info(\n                        \"Sent SendPulse notification to {}.\".format(target))\n                else:\n                    self.logger.debug(\"SendPulse authentication successful\")\n\n                return (True, response)\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending SendPulse \"\n                \"notification to {}.\".format(target))\n            self.logger.debug(\"Socket Exception: {}\".format(str(e)))\n\n        return (False, response)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"\n        Parses the URL and returns enough arguments that can allow\n        us to re-instantiate this object.\n\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Define our minimum requirements; defining them now saves us from\n        # having to if/else all kinds of branches below...\n        results[\"from_addr\"] = None\n        results[\"client_id\"] = None\n        results[\"client_secret\"] = None\n\n        # Prepare our targets\n        results[\"targets\"] = []\n\n        # Our URL looks like this:\n        #    {schema}://{from_addr}:{client_id}/{client_secret}/{targets}\n        #\n        # which actually equates to:\n        #    {schema}://{user}@{host}/{client_id}/{client_secret}\n        #                                  /{email1}/{email2}/etc..\n        #                 ^       ^\n        #                 |       |\n        #                -from addr-\n        if \"from\" in results[\"qsd\"]:\n            results[\"from_addr\"] = \\\n                NotifySendPulse.unquote(results[\"qsd\"][\"from\"].rstrip())\n\n            if is_email(results[\"from_addr\"]):\n                # Our hostname is free'd up to be interpreted as part of the\n                # targets\n                results[\"targets\"].append(\n                    NotifySendPulse.unquote(results[\"host\"]))\n                results[\"host\"] = \"\"\n\n        if \"user\" in results[\"qsd\"] and \\\n                is_email(NotifySendPulse.unquote(results[\"user\"])):\n            # Our hostname is free'd up to be interpreted as part of the\n            # targets\n            results[\"targets\"].append(NotifySendPulse.unquote(results[\"host\"]))\n            results[\"host\"] = \"\"\n\n        # Get our potential email targets\n        # First 2 elements are the client_id and client_secret\n        results[\"targets\"] += NotifySendPulse.split_path(results[\"fullpath\"])\n        # check for our client id\n        if \"id\" in results[\"qsd\"] and len(results[\"qsd\"][\"id\"]):\n            # Store our Client ID\n            results[\"client_id\"] = \\\n                NotifySendPulse.unquote(results[\"qsd\"][\"id\"])\n\n        elif results[\"targets\"]:\n            # Store our Client ID\n            results[\"client_id\"] = results[\"targets\"].pop(0)\n\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            # Store our Client Secret\n            results[\"client_secret\"] = \\\n                NotifySendPulse.unquote(results[\"qsd\"][\"secret\"])\n\n        elif results[\"targets\"]:\n            # Store our Client Secret\n            results[\"client_secret\"] = results[\"targets\"].pop(0)\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(\n                NotifySendPulse.unquote(results[\"qsd\"][\"to\"]))\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = \\\n                NotifySendPulse.unquote(results[\"qsd\"][\"cc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = \\\n                NotifySendPulse.unquote(results[\"qsd\"][\"bcc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"template\" in results[\"qsd\"] and len(results[\"qsd\"][\"template\"]):\n            results[\"template\"] = \\\n                NotifySendPulse.unquote(results[\"qsd\"][\"template\"])\n\n        # Add any template substitutions\n        results[\"template_data\"] = results[\"qsd+\"]\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/serverchan.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n# Register at https://sct.ftqq.com/\n#   - do as the page describe and you will get the token\n\n# Syntax:\n#  schan://{access_token}/\n\n\nclass NotifyServerChan(NotifyBase):\n    \"\"\"A wrapper for ServerChan Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"ServerChan\"\n\n    # The services URL\n    service_url = \"https://sct.ftqq.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"schan\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/serverchan/\"\n\n    # ServerChan API\n    notify_url = \"https://sctapi.ftqq.com/{token}.send\"\n\n    # Define object templates\n    templates = (\"{schema}://{token}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9-]+$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize ServerChan Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Token (associated with project)\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"An invalid ServerChan API Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform ServerChan Notification.\"\"\"\n        payload = {\n            \"title\": title,\n            \"desp\": body,\n        }\n\n        # Our Notification URL\n        notify_url = self.notify_url.format(token=self.token)\n\n        # Some Debug Logging\n        self.logger.debug(\n            \"ServerChan URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"ServerChan Payload: {payload}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=payload,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyServerChan.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send ServerChan notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n                return False\n\n            else:\n                self.logger.info(\"Sent ServerChan notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occured sending ServerChan notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def url(self, privacy=False):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        return \"{schema}://{token}\".format(\n            schema=self.secure_protocol,\n            token=self.pprint(self.token, privacy, safe=\"\"),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to\n        substantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't parse the URL\n            return results\n\n        pattern = \"schan://([a-zA-Z0-9]+)/\" + (\n            \"?\" if not url.endswith(\"/\") else \"\"\n        )\n        result = re.match(pattern, url)\n        results[\"token\"] = result.group(1) if result else \"\"\n        return results\n"
  },
  {
    "path": "apprise/plugins/ses.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API Information:\n# - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html\n#\n# AWS Credentials (access_key and secret_access_key)\n# - https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/\\\n#       setup-credentials.html\n# - https://docs.aws.amazon.com/toolkit-for-eclipse/v1/user-guide/\\\n#       setup-credentials.html\n#\n#      Other systems write these credentials to:\n#        -  ~/.aws/credentials on Linux, macOS, or Unix\n#        -  C:\\Users\\USERNAME\\.aws\\credentials on Windows\n#\n#\n#      To get A users access key ID and secret access key\n#\n#        1. Open the IAM console: https://console.aws.amazon.com/iam/home\n#        2. On the navigation menu, choose Users.\n#        3. Choose your IAM user name (not the check box).\n#        4. Open the Security credentials tab, and then choose:\n#             Create Access key - Programmatic access\n#        5. To see the new access key, choose Show. Your credentials resemble\n#           the following:\n#               Access key ID: AKIAIOSFODNN7EXAMPLE\n#               Secret access key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n#\n#      To download the key pair, choose Download .csv file. Store the keys\n#      The account requries this permssion to 'SES v2 : SendEmail' in order to\n#      work\n#\n#      To get the root users account (if you're logged in as that) you can\n#      visit: https://console.aws.amazon.com/iam/home#/\\\n#                 security_credentials$access_key\n#\n#    This information is vital to work with SES\n\n\n# To use/test the service, i logged into the portal via:\n#       - https://portal.aws.amazon.com\n#\n# Go to the dashboard of the Amazon SES (Simple Email Service)\n#  1. You must have a verified identity; click on that option and create one\n#     if you don't already have one. Until it's verified, you won't be able to\n#     do the next step.\n#  2. From here you'll be able to retrieve your ARN associated with your\n#     identity you want Apprise to send emails on behalf. It might look\n#     something like:\n#          arn:aws:ses:us-east-2:133216123003:identity/user@example.com\n#\n#  This is your ARN (Amazon Record Name)\n#\n#\n\nimport base64\nfrom collections import OrderedDict\nfrom datetime import datetime, timezone\nfrom email.header import Header\nfrom email.mime.application import MIMEApplication\nfrom email.mime.multipart import MIMEMultipart\nfrom email.mime.text import MIMEText\nfrom email.utils import formataddr\nfrom hashlib import sha256\nimport hmac\nimport re\nfrom urllib.parse import quote\nfrom xml.etree import ElementTree\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_email, parse_emails, validate_regex\nfrom .base import NotifyBase\n\n# Our Regin Identifier\n# support us-gov-west-1 syntax as well\nIS_REGION = re.compile(\n    r\"^\\s*(?P<country>[a-z]{2})-(?P<area>[a-z-]+?)-(?P<no>[0-9]+)\\s*$\", re.I\n)\n\n# Extend HTTP Error Messages\nAWS_HTTP_ERROR_MAP = {\n    403: \"Unauthorized - Invalid Access/Secret Key Combination.\",\n}\n\n\nclass NotifySES(NotifyBase):\n    \"\"\"A wrapper for AWS SES (Amazon Simple Email Service)\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"AWS Simple Email Service (SES)\"\n\n    # The services URL\n    service_url = \"https://aws.amazon.com/ses/\"\n\n    # The default secure protocol\n    secure_protocol = \"ses\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/ses/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # AWS is pretty good for handling data load so request limits\n    # can occur in much shorter bursts\n    request_rate_per_sec = 2.5\n\n    # Default Notify Format\n    notify_format = NotifyFormat.HTML\n\n    # Define object templates\n    templates = (\n        (\n            \"{schema}://{from_email}/{access_key_id}/{secret_access_key}/\"\n            \"{region}/{targets}\"\n        ),\n        \"{schema}://{from_email}/{access_key_id}/{secret_access_key}/{region}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"from_email\": {\n                \"name\": _(\"From Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"from_addr\",\n                \"required\": True,\n            },\n            \"access_key_id\": {\n                \"name\": _(\"Access Key ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"secret_access_key\": {\n                \"name\": _(\"Secret Access Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"region\": {\n                \"name\": _(\"Region\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z]{2}-[a-z-]+?-[0-9]+$\", \"i\"),\n                \"required\": True,\n                \"map_to\": \"region_name\",\n            },\n            \"targets\": {\n                \"name\": _(\"Target Emails\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"from_email\",\n            },\n            \"reply\": {\n                \"name\": _(\"Reply To Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"reply_to\",\n            },\n            \"name\": {\n                \"name\": _(\"From Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"from_name\",\n            },\n            \"access\": {\n                \"alias_of\": \"access_key_id\",\n            },\n            \"secret\": {\n                \"alias_of\": \"secret_access_key\",\n            },\n            \"region\": {\n                \"alias_of\": \"region\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        access_key_id,\n        secret_access_key,\n        region_name,\n        reply_to=None,\n        from_addr=None,\n        from_name=None,\n        targets=None,\n        cc=None,\n        bcc=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify AWS SES Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Store our AWS API Access Key\n        self.aws_access_key_id = validate_regex(access_key_id)\n        if not self.aws_access_key_id:\n            msg = \"An invalid AWS Access Key ID was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our AWS API Secret Access key\n        self.aws_secret_access_key = validate_regex(secret_access_key)\n        if not self.aws_secret_access_key:\n            msg = (\n                \"An invalid AWS Secret Access Key \"\n                f\"({secret_access_key}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire our AWS Region Name:\n        # eg. us-east-1, cn-north-1, us-west-2, ...\n        self.aws_region_name = validate_regex(\n            region_name, *self.template_tokens[\"region\"][\"regex\"]\n        )\n        if not self.aws_region_name:\n            msg = f\"An invalid AWS Region ({region_name}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire Email 'To'\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        # Set our notify_url based on our region\n        self.notify_url = f\"https://email.{self.aws_region_name}.amazonaws.com\"\n\n        # AWS Service Details\n        self.aws_service_name = \"ses\"\n        self.aws_canonical_uri = \"/\"\n\n        # AWS Authentication Details\n        self.aws_auth_version = \"AWS4\"\n        self.aws_auth_algorithm = \"AWS4-HMAC-SHA256\"\n        self.aws_auth_request = \"aws4_request\"\n\n        # Get our From username (if specified)\n        self.from_name = from_name\n\n        if from_addr:\n            self.from_addr = from_addr\n\n        else:\n            # Get our from email address\n            self.from_addr = f\"{self.user}@{self.host}\" if self.user else None\n\n        if not (self.from_addr and is_email(self.from_addr)):\n            msg = \"An invalid AWS From ({}) was specified.\".format(\n                f\"{self.user}@{self.host}\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.reply_to = None\n        if reply_to:\n            result = is_email(reply_to)\n            if not result:\n                msg = \"An invalid AWS Reply To ({}) was specified.\".format(\n                    f\"{reply_to}\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            self.reply_to = (\n                result[\"name\"] if result[\"name\"] else False,\n                result[\"full_email\"],\n            )\n\n        if targets:\n            # Validate recipients (to:) and drop bad ones:\n            for recipient in parse_emails(targets):\n                result = is_email(recipient)\n                if result:\n                    self.targets.append((\n                        result[\"name\"] if result[\"name\"] else False,\n                        result[\"full_email\"],\n                    ))\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid To email ({recipient}) specified.\",\n                )\n\n        else:\n            # If our target email list is empty we want to add ourselves to it\n            self.targets.append(\n                (self.from_name if self.from_name else False, self.from_addr)\n            )\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n            email = is_email(recipient)\n            if email:\n                self.cc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n            email = is_email(recipient)\n            if email:\n                self.bcc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Wrapper to send_notification since we can alert more then one\n        channel.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\"There are no SES email recipients to notify\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Initialize our default from name\n        from_name = (\n            self.from_name\n            if self.from_name\n            else (\n                self.reply_to[0]\n                if self.reply_to and self.reply_to[0]\n                else self.app_desc\n            )\n        )\n\n        reply_to = (\n            from_name,\n            self.from_addr if not self.reply_to else self.reply_to[1],\n        )\n\n        # Create a copy of the targets list\n        emails = list(self.targets)\n        while len(emails):\n            # Get our email to notify\n            to_name, to_addr = emails.pop(0)\n\n            # Strip target out of cc list if in To or Bcc\n            cc = self.cc - self.bcc - {to_addr}\n\n            # Strip target out of bcc list if in To\n            bcc = self.bcc - {to_addr}\n\n            # Format our cc addresses to support the Name field\n            cc = [\n                formataddr(\n                    (self.names.get(addr, False), addr), charset=\"utf-8\"\n                )\n                for addr in cc\n            ]\n\n            # Format our bcc addresses to support the Name field\n            bcc = [\n                formataddr(\n                    (self.names.get(addr, False), addr), charset=\"utf-8\"\n                )\n                for addr in bcc\n            ]\n\n            self.logger.debug(\n                \"Email From: {} <{}>\".format(\n                    quote(reply_to[0], \" \"), quote(reply_to[1], \"@ \")\n                )\n            )\n\n            self.logger.debug(f\"Email To: {to_addr}\")\n            if cc:\n                self.logger.debug(\"Email Cc: {}\".format(\", \".join(cc)))\n            if bcc:\n                self.logger.debug(\"Email Bcc: {}\".format(\", \".join(bcc)))\n\n            # Prepare Email Message\n            if self.notify_format == NotifyFormat.HTML:\n                content = MIMEText(body, \"html\", \"utf-8\")\n\n            else:\n                content = MIMEText(body, \"plain\", \"utf-8\")\n\n            # Create a Multipart container if there is an attachment\n            base = (\n                MIMEMultipart()\n                if attach and self.attachment_support\n                else content\n            )\n\n            # TODO: Deduplicate with `NotifyEmail`?\n            base[\"Subject\"] = Header(title, \"utf-8\")\n            base[\"From\"] = formataddr(\n                (from_name if from_name else False, self.from_addr),\n                charset=\"utf-8\",\n            )\n            base[\"To\"] = formataddr((to_name, to_addr), charset=\"utf-8\")\n            if reply_to[1] != self.from_addr:\n                base[\"Reply-To\"] = formataddr(reply_to, charset=\"utf-8\")\n            base[\"Cc\"] = \",\".join(cc)\n            base[\"Date\"] = datetime.now(timezone.utc).strftime(\n                \"%a, %d %b %Y %H:%M:%S +0000\"\n            )\n            base[\"X-Application\"] = self.app_id\n\n            if attach and self.attachment_support:\n                # First attach our body to our content as the first element\n                base.attach(content)\n\n                # Now store our attachments\n                for no, attachment in enumerate(attach, start=1):\n                    if not attachment:\n                        # We could not load the attachment; take an early\n                        # exit since this isn't what the end user wanted\n\n                        # We could not access the attachment\n                        self.logger.error(\n                            \"Could not access attachment\"\n                            f\" {attachment.url(privacy=True)}.\"\n                        )\n\n                        return False\n\n                    self.logger.debug(\n                        \"Preparing Email attachment\"\n                        f\" {attachment.url(privacy=True)}\"\n                    )\n\n                    with open(attachment.path, \"rb\") as abody:\n                        app = MIMEApplication(abody.read())\n                        app.set_type(attachment.mimetype)\n\n                        filename = (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        )\n\n                        app.add_header(\n                            \"Content-Disposition\",\n                            'attachment; filename=\"{}\"'.format(\n                                Header(filename, \"utf-8\")\n                            ),\n                        )\n\n                        base.attach(app)\n\n            # Prepare our payload object\n            payload = {\n                \"Action\": \"SendRawEmail\",\n                \"Version\": \"2010-12-01\",\n                \"RawMessage.Data\": (\n                    base64.b64encode(base.as_string().encode(\"utf-8\")).decode(\n                        \"utf-8\"\n                    )\n                ),\n            }\n\n            for no, email in enumerate(([to_addr, *bcc, *cc]), start=1):\n                payload[f\"Destinations.member.{no}\"] = email\n\n            # Specify from address\n            payload[\"Source\"] = \"{} <{}>\".format(\n                quote(from_name, \" \"), quote(self.from_addr, \"@ \")\n            )\n\n            (result, _response) = self._post(payload=payload, to=to_addr)\n            if not result:\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    def _post(self, payload, to):\n        \"\"\"Wrapper to request.post() to manage it's response better and make\n        the send() function cleaner and easier to maintain.\n\n        This function returns True if the _post was successful and False if it\n        wasn't.\n        \"\"\"\n\n        # Always call throttle before any remote server i/o is made; for AWS\n        # time plays a huge factor in the headers being sent with the payload.\n        # So for AWS (SES) requests we must throttle before they're generated\n        # and not directly before the i/o call like other notification\n        # services do.\n        self.throttle()\n\n        # Convert our payload from a dict() into a urlencoded string\n        payload = NotifySES.urlencode(payload)\n\n        # Prepare our Notification URL\n        # Prepare our AWS Headers based on our payload\n        headers = self.aws_prepare_request(payload)\n\n        self.logger.debug(\n            \"AWS SES POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(\"AWS SES Payload (%d bytes)\", len(payload))\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifySES.http_response_code_lookup(\n                    r.status_code, AWS_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send AWS SES notification to {}: \"\n                    \"{}{}error={}.\".format(\n                        to,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return (False, NotifySES.aws_response_to_dict(r.text))\n\n            else:\n                self.logger.info(f'Sent AWS SES notification to \"{to}\".')\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending AWS SES \"\n                f'notification to \"{to}\".',\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return (False, NotifySES.aws_response_to_dict(None))\n\n        return (True, NotifySES.aws_response_to_dict(r.text))\n\n    def aws_prepare_request(self, payload, reference=None):\n        \"\"\"Takes the intended payload and returns the headers for it.\n\n        The payload is presumed to have been already urlencoded()\n        \"\"\"\n\n        # Define our AWS SES header\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n            # Populated below\n            \"Content-Length\": 0,\n            \"Authorization\": None,\n            \"X-Amz-Date\": None,\n        }\n\n        # Get a reference time (used for header construction)\n        reference = datetime.now(timezone.utc)\n\n        # Provide Content-Length\n        headers[\"Content-Length\"] = str(len(payload))\n\n        # Amazon Date Format\n        amzdate = reference.strftime(\"%Y%m%dT%H%M%SZ\")\n        headers[\"X-Amz-Date\"] = amzdate\n\n        # Credential Scope\n        scope = \"{date}/{region}/{service}/{request}\".format(\n            date=reference.strftime(\"%Y%m%d\"),\n            region=self.aws_region_name,\n            service=self.aws_service_name,\n            request=self.aws_auth_request,\n        )\n\n        # Similar to headers; but a subset.  keys must be lowercase\n        signed_headers = OrderedDict([\n            (\"content-type\", headers[\"Content-Type\"]),\n            (\"host\", f\"email.{self.aws_region_name}.amazonaws.com\"),\n            (\"x-amz-date\", headers[\"X-Amz-Date\"]),\n        ])\n\n        #\n        # Build Canonical Request Object\n        #\n        canonical_request = \"\\n\".join([\n            # Method\n            \"POST\",\n            # URL\n            self.aws_canonical_uri,\n            # Query String (none set for POST)\n            \"\",\n            # Header Content (must include \\n at end!)\n            # All entries except characters in amazon date must be\n            # lowercase\n            \"\\n\".join([f\"{k}:{v}\" for k, v in signed_headers.items()]) + \"\\n\",\n            # Header Entries (in same order identified above)\n            \";\".join(signed_headers.keys()),\n            # Payload\n            sha256(payload.encode(\"utf-8\")).hexdigest(),\n        ])\n\n        # Prepare Unsigned Signature\n        to_sign = \"\\n\".join([\n            self.aws_auth_algorithm,\n            amzdate,\n            scope,\n            sha256(canonical_request.encode(\"utf-8\")).hexdigest(),\n        ])\n\n        # Our Authorization header\n        headers[\"Authorization\"] = \", \".join([\n            (\n                f\"{self.aws_auth_algorithm} \"\n                f\"Credential={self.aws_access_key_id}/{scope}\"\n            ),\n            \"SignedHeaders={signed_headers}\".format(\n                signed_headers=\";\".join(signed_headers.keys()),\n            ),\n            f\"Signature={self.aws_auth_signature(to_sign, reference)}\",\n        ])\n\n        return headers\n\n    def aws_auth_signature(self, to_sign, reference):\n        \"\"\"Generates a AWS v4 signature based on provided payload which should\n        be in the form of a string.\"\"\"\n\n        def _sign(key, msg, to_hex=False):\n            \"\"\"Perform AWS Signing.\"\"\"\n            if to_hex:\n                return hmac.new(key, msg.encode(\"utf-8\"), sha256).hexdigest()\n            return hmac.new(key, msg.encode(\"utf-8\"), sha256).digest()\n\n        date = _sign(\n            (self.aws_auth_version + self.aws_secret_access_key).encode(\n                \"utf-8\"\n            ),\n            reference.strftime(\"%Y%m%d\"),\n        )\n\n        region = _sign(date, self.aws_region_name)\n        service = _sign(region, self.aws_service_name)\n        signed = _sign(service, self.aws_auth_request)\n        return _sign(signed, to_sign, to_hex=True)\n\n    @staticmethod\n    def aws_response_to_dict(aws_response):\n        \"\"\"Takes an AWS Response object as input and returns it as a dictionary\n        but not befor extracting out what is useful to us first.\n\n        eg:\n          IN:\n\n            <SendRawEmailResponse\n                 xmlns=\"http://ses.amazonaws.com/doc/2010-12-01/\">\n              <SendRawEmailResult>\n                <MessageId>\n                   010f017d87656ee2-a2ea291f-79ea-\n                   44f3-9d25-00d041de3007-000000</MessageId>\n              </SendRawEmailResult>\n              <ResponseMetadata>\n                <RequestId>7abb454e-904b-4e46-a23c-2f4d2fc127a6</RequestId>\n              </ResponseMetadata>\n            </SendRawEmailResponse>\n\n          OUT:\n           {\n             'type': 'SendRawEmailResponse',\n              'message_id': '010f017d87656ee2-a2ea291f-79ea-\n                             44f3-9d25-00d041de3007-000000',\n              'request_id': '7abb454e-904b-4e46-a23c-2f4d2fc127a6',\n           }\n        \"\"\"\n\n        # Define ourselves a set of directives we want to keep if found and\n        # then identify the value we want to map them to in our response\n        # object\n        aws_keep_map = {\n            \"RequestId\": \"request_id\",\n            \"MessageId\": \"message_id\",\n            # Error Message Handling\n            \"Type\": \"error_type\",\n            \"Code\": \"error_code\",\n            \"Message\": \"error_message\",\n        }\n\n        # A default response object that we'll manipulate as we pull more data\n        # from our AWS Response object\n        response = {\n            \"type\": None,\n            \"request_id\": None,\n            \"message_id\": None,\n        }\n\n        try:\n            # we build our tree, but not before first eliminating any\n            # reference to namespacing (if present) as it makes parsing\n            # the tree so much easier.\n            root = ElementTree.fromstring(\n                re.sub(r' xmlns=\"[^\"]+\"', \"\", aws_response, count=1)\n            )\n\n            # Store our response tag object name\n            response[\"type\"] = str(root.tag)\n\n            def _xml_iter(root, response):\n                if len(root) > 0:\n                    for child in root:\n                        # use recursion to parse everything\n                        _xml_iter(child, response)\n\n                elif root.tag in aws_keep_map:\n                    response[aws_keep_map[root.tag]] = (root.text).strip()\n\n            # Recursivly iterate over our AWS Response to extract the\n            # fields we're interested in in efforts to populate our response\n            # object.\n            _xml_iter(root, response)\n\n        except (ElementTree.ParseError, TypeError):\n            # bad data just causes us to generate a bad response\n            pass\n\n        return response\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.from_addr,\n            self.aws_access_key_id,\n            self.aws_secret_access_key,\n            self.aws_region_name,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Acquire any global URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if self.from_name is not None:\n            # from_name specified; pass it back on the url\n            params[\"name\"] = self.from_name\n\n        if self.cc:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                \"{}{}\".format(\n                    \"\" if not e not in self.names else f\"{self.names[e]}:\",\n                    e,\n                )\n                for e in self.cc\n            ])\n\n        if self.bcc:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join(self.bcc)\n\n        if self.reply_to:\n            # Handle our reply to address\n            params[\"reply\"] = (\n                \"{} <{}>\".format(*self.reply_to)\n                if self.reply_to[0]\n                else self.reply_to[1]\n            )\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0][1] == self.from_addr\n        )\n\n        return (\n            \"{schema}://{from_addr}/{key_id}/{key_secret}/{region}/\"\n            \"{targets}/?{params}\".format(\n                schema=self.secure_protocol,\n                from_addr=NotifySES.quote(self.from_addr, safe=\"@\"),\n                key_id=self.pprint(self.aws_access_key_id, privacy, safe=\"\"),\n                key_secret=self.pprint(\n                    self.aws_secret_access_key,\n                    privacy,\n                    mode=PrivacyMode.Secret,\n                    safe=\"\",\n                ),\n                region=NotifySES.quote(self.aws_region_name, safe=\"\"),\n                targets=(\n                    \"\"\n                    if not has_targets\n                    else \"/\".join([\n                        NotifySES.quote(\n                            \"{}{}\".format(\n                                \"\" if not e[0] else f\"{e[0]}:\", e[1]\n                            ),\n                            safe=\"\",\n                        )\n                        for e in self.targets\n                    ])\n                ),\n                params=NotifySES.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        entries = NotifySES.split_path(results[\"fullpath\"])\n\n        # The AWS Access Key ID is stored in the first entry\n        access_key_id = entries.pop(0) if entries else None\n\n        # Our AWS Access Key Secret contains slashes in it which unfortunately\n        # means it is of variable length after the hostname.  Since we require\n        # that the user provides the region code, we intentionally use this\n        # as our delimiter to detect where our Secret is.\n        secret_access_key = None\n        region_name = None\n\n        # We need to iterate over each entry in the fullpath and find our\n        # region. Once we get there we stop and build our secret from our\n        # accumulated data.\n        secret_access_key_parts = []\n\n        # Section 1: Get Region and Access Secret\n        index = 0\n        for index, entry in enumerate(entries, start=1):\n\n            # Are we at the region yet?\n            result = IS_REGION.match(entry)\n            if result:\n                # Ensure region is nicely formatted\n                region_name = \"{country}-{area}-{no}\".format(\n                    country=result.group(\"country\").lower(),\n                    area=result.group(\"area\").lower(),\n                    no=result.group(\"no\"),\n                )\n\n                # We're done with Section 1 of our url (the credentials)\n                break\n\n            elif is_email(entry):\n                # We're done with Section 1 of our url (the credentials)\n                index -= 1\n                break\n\n            # Store our secret parts\n            secret_access_key_parts.append(entry)\n\n        # Prepare our Secret Access Key\n        secret_access_key = (\n            \"/\".join(secret_access_key_parts)\n            if secret_access_key_parts\n            else None\n        )\n\n        # Section 2: Get our Recipients (basically all remaining entries)\n        results[\"targets\"] = entries[index:]\n\n        if \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n            # Extract from name to associate with from address\n            results[\"from_name\"] = NotifySES.unquote(results[\"qsd\"][\"name\"])\n\n        # Handle 'to' email address\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(results[\"qsd\"][\"to\"])\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = NotifySES.parse_list(results[\"qsd\"][\"cc\"])\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = NotifySES.parse_list(results[\"qsd\"][\"bcc\"])\n\n        # Handle From Address handling\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"from_addr\"] = NotifySES.unquote(results[\"qsd\"][\"from\"])\n\n        # Handle Reply To Address\n        if \"reply\" in results[\"qsd\"] and len(results[\"qsd\"][\"reply\"]):\n            results[\"reply_to\"] = NotifySES.unquote(results[\"qsd\"][\"reply\"])\n\n        # Handle secret_access_key over-ride\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            results[\"secret_access_key\"] = NotifySES.unquote(\n                results[\"qsd\"][\"secret\"]\n            )\n        else:\n            results[\"secret_access_key\"] = secret_access_key\n\n        # Handle access key id over-ride\n        if \"access\" in results[\"qsd\"] and len(results[\"qsd\"][\"access\"]):\n            results[\"access_key_id\"] = NotifySES.unquote(\n                results[\"qsd\"][\"access\"]\n            )\n        else:\n            results[\"access_key_id\"] = access_key_id\n\n        # Handle region name id over-ride\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            results[\"region_name\"] = NotifySES.unquote(\n                results[\"qsd\"][\"region\"]\n            )\n        else:\n            results[\"region_name\"] = region_name\n\n        # Return our result set\n        return results\n"
  },
  {
    "path": "apprise/plugins/seven.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n# Create an account https://www.seven.io if you don't already have one\n#\n# Get your (apikey) from here:\n#   - https://help.seven.io/en/api-key-access\n#\nimport json\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_phone_no, parse_bool, parse_phone_no\nfrom .base import NotifyBase\n\n\nclass NotifySeven(NotifyBase):\n    \"\"\"A wrapper for seven Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"seven\"\n\n    # The services URL\n    service_url = \"https://www.seven.io\"\n\n    # The default protocol\n    secure_protocol = \"seven\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/seven/\"\n\n    # Seven uses the http protocol with JSON requests\n    notify_url = \"https://gateway.seven.io/api/sms\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"source\": {\n                # Originating address,In cases where the rewriting of the\n                # sender's address is supported or permitted by the SMS-C.\n                # This is used to transmit the message, this number is\n                # transmitted as the originating address and is completely\n                # optional.\n                \"name\": _(\"Originating Address\"),\n                \"type\": \"string\",\n                \"map_to\": \"source\",\n            },\n            \"from\": {\n                \"alias_of\": \"source\",\n            },\n            \"flash\": {\n                \"name\": _(\"Flash\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"label\": {\"name\": _(\"Label\"), \"type\": \"string\"},\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        apikey,\n        targets=None,\n        source=None,\n        flash=None,\n        label=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Seven Object.\"\"\"\n        super().__init__(**kwargs)\n        # API Key (associated with project)\n        self.apikey = apikey\n        if not self.apikey:\n            msg = f\"An invalid seven API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.source = None if not isinstance(source, str) else source.strip()\n        self.flash = (\n            self.template_args[\"flash\"][\"default\"]\n            if flash is None\n            else bool(flash)\n        )\n        self.label = None if not isinstance(label, str) else label.strip()\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another similar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform seven Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no seven targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n            \"SentWith\": \"Apprise\",\n            \"X-Api-Key\": self.apikey,\n        }\n\n        # Prepare our payload\n        payload = {\n            \"to\": None,\n            \"text\": body,\n        }\n        if self.source:\n            payload[\"from\"] = self.source\n        if self.flash:\n            payload[\"flash\"] = self.flash\n        if self.label:\n            payload[\"label\"] = self.label\n        # Create a copy of the targets list\n        targets = list(self.targets)\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n            # Prepare our user\n            payload[\"to\"] = f\"+{target}\"\n            # Some Debug Logging\n            self.logger.debug(\n                \"seven POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"seven Payload: {payload}\")\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=json.dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                # Sample output of a successful transmission\n                # {\n                #     \"success\": \"100\",\n                #     \"total_price\": 0.075,\n                #     \"balance\": 46.748,\n                #     \"debug\": \"false\",\n                #     \"sms_type\": \"direct\",\n                #     \"messages\": [\n                #         {\n                #             \"id\": \"77229135982\",\n                #             \"sender\": \"492022839080\",\n                #             \"recipient\": \"4917661254799\",\n                #             \"text\": \"x\",\n                #             \"encoding\": \"gsm\",\n                #             \"label\": null,\n                #             \"parts\": 1,\n                #             \"udh\": null,\n                #             \"is_binary\": false,\n                #             \"price\": 0.075,\n                #             \"success\": true,\n                #             \"error\": null,\n                #             \"error_text\": null\n                #         }\n                #     ]\n                # }\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.created,\n                ):\n                    # We had a problem\n                    status_str = NotifySeven.http_response_code_lookup(\n                        r.status_code\n                    )\n                    self.logger.warning(\n                        \"Failed to send seven notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            \",\".join(target),\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n                else:\n                    self.logger.info(f\"Sent seven notification to {target}.\")\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending seven:{target} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n                # Mark our failure\n                has_error = True\n                continue\n        return not has_error\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        params = {\n            \"flash\": \"yes\" if self.flash else \"no\",\n        }\n        if self.source:\n            params[\"from\"] = self.source\n        if self.label:\n            params[\"label\"] = self.label\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{apikey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifySeven.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifySeven.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifySeven.split_path(results[\"fullpath\"])\n\n        # The hostname is our authentication key\n        results[\"apikey\"] = NotifySeven.unquote(results[\"host\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySeven.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Support the 'from' and source variable\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifySeven.unquote(results[\"qsd\"][\"from\"])\n\n        elif \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifySeven.unquote(results[\"qsd\"][\"source\"])\n\n        results[\"flash\"] = parse_bool(results[\"qsd\"].get(\"flash\", False))\n        results[\"label\"] = results[\"qsd\"].get(\"label\", None)\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/sfr.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this to work correctly you need to have a valid SFR DMC service account\n# to whicthe API password can be generated. A \"space\" is also necessary\n# (space = a logical separation between clients), which will give you a\n# specific spaceId\n#\n# Expected credentials looks a little like this:\n# serviceId: 84920958892    - Random numbers\n# servicePassword: XxXXxXXx - Random characters\n# spaceId: 984348           - Random numbers\n#\n# 1. Visit https://www.sfr.fr/\n#\n# 2. Url will look like this\n#    https://www.dmc.sfr-sh.fr/DmcWS/1.5.8/JsonService/<apiGroup>/<apicall>\n\nimport json\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_phone_no\nfrom .base import NotifyBase\n\n\nclass NotifySFR(NotifyBase):\n    \"\"\"A wrapper for SFR French Telecom DMC API.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Société Française du Radiotéléphone\")\n\n    # The services URL\n    service_url = \"https://www.sfr.fr/\"\n\n    # The default protocol\n    protocol = \"sfr\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/sfr/\"\n\n    # SFR api\n    notify_url = (\n        \"https://www.dmc.sfr-sh.fr/DmcWS/1.5.8/JsonService/\"\n        \"MessagesUnitairesWS/addSingleCall\"  # this is the actual api call\n    )\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{user}:{password}@{space_id}/{targets}\",)\n\n    # Define our tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"Service ID\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Service Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"space_id\": {\n                \"name\": _(\"Space ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target\": {\n                \"name\": _(\"Recipient Phone Number\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"lang\": {\n                \"name\": _(\"Language\"),\n                \"type\": \"string\",\n                \"default\": \"fr_FR\",\n                \"required\": True,\n            },\n            \"sender\": {\n                \"name\": _(\"Sender Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"default\": \"\",\n            },\n            \"from\": {\"alias_of\": \"sender\"},\n            \"media\": {\n                \"name\": _(\"Media Type\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"default\": \"SMSUnicode\",\n                \"values\": [\"SMS\", \"SMSLong\", \"SMSUnicode\", \"SMSUnicodeLong\"],\n            },\n            \"timeout\": {\n                \"name\": _(\"Timeout\"),\n                \"type\": \"int\",\n                \"default\": 2880,\n                \"required\": False,\n            },\n            \"voice\": {\n                \"name\": _(\"TTS Voice\"),\n                \"type\": \"string\",\n                \"default\": \"claire08s\",\n                \"values\": [\"claire08s\", \"laura8k\"],\n                \"required\": False,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        space_id=None,\n        targets=None,\n        lang=None,\n        sender=None,\n        media=None,\n        timeout=None,\n        voice=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize SFR Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if not (self.user and self.password):\n            msg = (\n                \"A SFR user (serviceId) and password (servicePassword) \"\n                \"combination was not provided.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.space_id = space_id\n        if not self.space_id:\n            msg = \"A SFR Space ID is required.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.voice = voice if voice else self.template_args[\"voice\"][\"default\"]\n        self.lang = lang if lang else self.template_args[\"lang\"][\"default\"]\n        self.media = media if media else self.template_args[\"media\"][\"default\"]\n        self.sender = (\n            sender if sender else self.template_args[\"sender\"][\"default\"]\n        )\n\n        # Set our Time to Live Flag\n        self.timeout = self.template_args[\"timeout\"][\"default\"]\n        try:\n            self.timeout = int(timeout)\n\n        except (ValueError, TypeError):\n            # set default timeout\n            self.timeout = 2880\n            pass\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n        if not self.targets:\n            msg = (\n                \"No receiver phone number has been provided. Please \"\n                \"provide as least one valid phone number.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform the SFR notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        # Construct the authentication JSON\n        auth_payload = json.dumps({\n            \"serviceId\": self.user,\n            \"servicePassword\": self.password,\n            \"spaceId\": self.space_id,\n            \"lang\": self.lang,\n        })\n\n        base_payload = {\n            # Can be 'SMS', 'SMSLong', 'SMSUnicode', or 'SMSUnicodeLong'\n            \"media\": self.media,\n            # Content of the message\n            \"textMsg\": body,\n            # Receiver's phone number (set below)\n            \"to\": None,\n            # Optional, default to ''\n            \"from\": self.sender,\n            # Optional, default 2880 minutes\n            \"timeout\": self.timeout,\n            # Optional, default to French voice\n            \"ttsVoice\": self.voice,\n        }\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our target phone no\n            base_payload[\"to\"] = target\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            # Finalize our payload\n            payload = {\n                \"authenticate\": auth_payload,\n                \"messageUnitaire\": json.dumps(base_payload, ensure_ascii=True),\n            }\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"SFR POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"SFR Payload: {payload}\")\n\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    params=payload,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                try:\n                    content = json.loads(r.content)\n\n                except (AttributeError, TypeError, ValueError):\n                    # ValueError = r.content is Unparsable\n                    # TypeError = r.content is None\n                    # AttributeError = r is None\n                    content = {}\n\n                # Check if the request was successfull\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.no_content,\n                ):\n                    # We had a problem\n                    status_str = NotifySFR.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send SFR notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                # SFR returns a code 200 even if the authentication fails\n                # It then indicates in the content['success'] field the\n                # Actual state of the transaction\n                if not content.get(\"success\", False):\n                    self.logger.warning(\n                        \"SFR Notification to {} was not sent by the server: \"\n                        \"server_error={}, fatal={}.\".format(\n                            target,\n                            content.get(\"errorCode\", \"UNKNOWN\"),\n                            content.get(\"fatal\", \"True\"),\n                        )\n                    )\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                self.logger.info(f\"Sent SFR notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending SFR:{target} \"\n                    \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.space_id,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n        # Define any URL parameters\n        params = {\n            \"from\": self.sender,\n            \"timeout\": str(self.timeout),\n            \"voice\": self.voice,\n            \"lang\": self.lang,\n            \"media\": self.media,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{user}:{password}@{sid}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            user=self.user,\n            password=self.pprint(\n                self.password,\n                privacy,\n                mode=PrivacyMode.Secret,\n                safe=\"\",\n            ),\n            sid=self.pprint(self.space_id, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifySFR.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=self.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parse the URL and return arguments required to initialize this\n        plugin.\"\"\"\n        # NotifyBase.parse_url() will make the initial parsing of your string\n        # very easy to use. It will tokenize the entire URL for you.  The\n        # tokens are then passed into your __init__() function you defined to\n        # generate you're object\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Extract user and password\n        results[\"space_id\"] = results.get(\"host\")\n        results[\"targets\"] = NotifySFR.split_path(results[\"fullpath\"])\n\n        # Extract additional parameters\n        qsd = results.get(\"qsd\", {})\n        results[\"sender\"] = NotifySFR.unquote(\n            qsd.get(\"sender\", qsd.get(\"from\"))\n        )\n        results[\"timeout\"] = NotifySFR.unquote(qsd.get(\"timeout\"))\n        results[\"voice\"] = NotifySFR.unquote(qsd.get(\"voice\"))\n        results[\"lang\"] = NotifySFR.unquote(qsd.get(\"lang\"))\n        results[\"media\"] = NotifySFR.unquote(qsd.get(\"media\"))\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySFR.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/signal_api.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom __future__ import annotations\n\nfrom json import dumps\nimport logging\nimport re\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_bool, parse_phone_no\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase, NotifyFormat\n\nGROUP_REGEX = re.compile(\n    r\"^\\s*((\\@|\\%40)?(group\\.)|\\@|\\%40)(?P<group>[a-z0-9_=-]+)\", re.I\n)\n\n\nclass NotifySignalAPI(NotifyBase):\n    \"\"\"A wrapper for SignalAPI Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Signal API\"\n\n    # The services URL\n    service_url = \"https://bbernhard.github.io/signal-cli-rest-api/\"\n\n    # The default protocol\n    protocol = \"signal\"\n\n    # The default protocol\n    secure_protocol = \"signals\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/signal/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # The maximum targets to include when doing batch transfers\n    default_batch_size = 10\n\n    # We don't support titles for Signal notifications\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{from_phone}\",\n        \"{schema}://{host}:{port}/{from_phone}\",\n        \"{schema}://{user}@{host}/{from_phone}\",\n        \"{schema}://{user}@{host}:{port}/{from_phone}\",\n        \"{schema}://{user}:{password}@{host}/{from_phone}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{from_phone}\",\n        \"{schema}://{host}/{from_phone}/{targets}\",\n        \"{schema}://{host}:{port}/{from_phone}/{targets}\",\n        \"{schema}://{user}@{host}/{from_phone}/{targets}\",\n        \"{schema}://{user}@{host}:{port}/{from_phone}/{targets}\",\n        \"{schema}://{user}:{password}@{host}/{from_phone}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{from_phone}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Group ID\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"regex\": (r\"^[a-z0-9_=-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"from_phone\",\n            },\n            \"status\": {\n                \"name\": _(\"Show Status\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self, source=None, targets=None, batch=False, status=False, **kwargs\n    ):\n        \"\"\"Initialize SignalAPI Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        # Set Status type\n        self.status = status\n\n        # Parse our targets\n        self.targets = []\n\n        # Used for URL generation afterwards only\n        self.invalid_targets = []\n\n        # Manage our Source Phone\n        result = is_phone_no(source)\n        if not result:\n            msg = (\n                \"An invalid Signal API Source Phone No \"\n                f\"({source}) was provided.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.source = \"+{}\".format(result[\"full\"])\n\n        if targets:\n            # Validate our targerts\n            for target in parse_phone_no(targets):\n                # Validate targets and drop bad ones:\n                result = is_phone_no(target)\n                if result:\n                    # store valid phone number\n                    self.targets.append(\"+{}\".format(result[\"full\"]))\n                    continue\n\n                result = GROUP_REGEX.match(target)\n                if result:\n                    # Just store group information\n                    self.targets.append(\n                        \"group.{}\".format(result.group(\"group\"))\n                    )\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid phone/group ({target}) specified.\",\n                )\n                self.invalid_targets.append(target)\n                continue\n\n        else:\n            # Send a message to ourselves\n            self.targets.append(self.source)\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Signal API Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no Signal API targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        attachments = []\n        if attach and self.attachment_support:\n            for attachment in attach:\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Signal API attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    attachments.append(attachment.base64())\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access Signal API attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending Signal API attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Support Styled (Markdown formatting)\n        text_mode = (\n            \"styled\" if self.notify_format == NotifyFormat.MARKDOWN\n            else \"normal\"\n        )\n\n        # Format defined here:\n        #   https://bbernhard.github.io/signal-cli-rest-api\\\n        #       /#/Messages/post_v2_send\n        # Example:\n        # {\n        #   \"base64_attachments\": [\n        #     \"string\"\n        #   ],\n        #   \"message\": \"string\",\n        #   \"number\": \"string\",\n        #   \"recipients\": [\n        #     \"string\"\n        #   ]\n        # }\n        # Prepare our payload\n        payload = {\n            \"message\": (\n                \"{}{}\".format(\n                    (\n                        \"\"\n                        if not self.status\n                        else f\"{self.asset.ascii(notify_type)} \"\n                    ),\n                    body,\n                ).rstrip()\n            ),\n            \"number\": self.source,\n            \"text_mode\": text_mode,\n            \"recipients\": [],\n        }\n\n        if attachments:\n            # Store our attachments\n            payload[\"base64_attachments\"] = attachments\n\n        # Determine Authentication\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        # Construct our URL\n        notify_url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            notify_url += f\":{self.port}\"\n        notify_url += \"/v2/send\"\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        for index in range(0, len(self.targets), batch_size):\n            # Prepare our recipients\n            payload[\"recipients\"] = self.targets[index : index + batch_size]\n\n            # Some Debug Logging\n            if self.logger.isEnabledFor(logging.DEBUG):\n                # Due to attachments; output can be quite heavy and io\n                # intensive.\n                # To accommodate this, we only show our debug payload\n                # information if required.\n                self.logger.debug(\n                    \"Signal API POST URL:\"\n                    f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n                )\n                log_payload = dict(payload)\n                log_payload.pop(\"recipients\", None)\n                self.logger.debug(\n                    \"Signal API Payload: %s\", sanitize_payload(log_payload))\n                self.logger.debug(\n                    \"Signal API Recipients: %s\",\n                    payload.get(\"recipients\", []),\n                )\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    notify_url,\n                    auth=auth,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.created,\n                ):\n                    # We had a problem\n                    status_str = NotifySignalAPI.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send {} Signal API notification{}: \"\n                        \"{}{}error={}.\".format(\n                            len(self.targets[index : index + batch_size]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if batch_size == 1\n                                else \"(s)\"\n                            ),\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        \"Sent {} Signal API notification{}.\".format(\n                            len(self.targets[index : index + batch_size]),\n                            (\n                                f\" to {self.targets[index]}\"\n                                if batch_size == 1\n                                else \"(s)\"\n                            ),\n                        )\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occured sending\"\n                    f\" {len(self.targets[index:index + batch_size])} Signal\"\n                    \" API notification(s).\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n            self.source,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"status\": \"yes\" if self.status else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifySignalAPI.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifySignalAPI.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n\n        # So we can strip out our own phone (if present); create a copy of our\n        # targets\n        if len(self.targets) == 1 and self.source in self.targets:\n            targets = []\n\n        elif len(self.targets) == 0:\n            # invalid phone-no were specified\n            targets = self.invalid_targets\n\n        else:\n            # append @ to non-phone number entries as they are groups\n            # Remove group. prefix as well\n            targets = [f\"@{x[6:]}\" if x[0] != \"+\" else x for x in self.targets]\n\n        return \"{schema}://{auth}{hostname}{port}/{src}/{dst}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            src=self.source,\n            dst=\"/\".join(\n                [NotifySignalAPI.quote(x, safe=\"@+\") for x in targets]\n            ),\n            params=NotifySignalAPI.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifySignalAPI.split_path(results[\"fullpath\"])\n\n        # The hostname is our authentication key\n        results[\"apikey\"] = NotifySignalAPI.unquote(results[\"host\"])\n\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifySignalAPI.unquote(results[\"qsd\"][\"from\"])\n\n        elif results[\"targets\"]:\n            # The from phone no is the first entry in the list otherwise\n            results[\"source\"] = results[\"targets\"].pop(0)\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySignalAPI.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(results[\"qsd\"].get(\"batch\", False))\n\n        # Get status switch\n        results[\"status\"] = parse_bool(results[\"qsd\"].get(\"status\", False))\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/signl4.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API Refererence:\n#   - https://docs.signl4.com/integrations/webhook/webhook.html\n#\n\nfrom json import dumps\nfrom typing import Any, Optional\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifySIGNL4(NotifyBase):\n    \"\"\"\n    A wrapper for SIGNL4 Notifications\n    \"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SIGNL4\"\n\n    # The services URL\n    service_url = \"https://signl4.com/\"\n\n    # Secure Protocol\n    secure_protocol = \"signl4\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/signl4/\"\n\n    # Our event action type\n    event_action = \"trigger\"\n\n    # Our default notification URL\n    notify_url = \"https://connect.signl4.com/webhook/{secret}/\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{secret}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(NotifyBase.template_tokens, **{\n        # SIGNL4 team or integration secret\n        \"secret\": {\n            \"name\": _(\"Secret\"),\n            \"type\": \"string\",\n            \"private\": True,\n            \"required\": True\n        },\n    })\n\n    # Define our template arguments\n    template_args = dict(NotifyBase.template_args, **{\n        \"service\": {\n            \"name\": _(\"Service\"),\n            \"type\": \"string\",\n        },\n        \"location\": {\n            \"name\": _(\"Location\"),\n            \"type\": \"string\",\n        },\n        \"alerting_scenario\": {\n            \"name\": _(\"Alerting Scenario\"),\n            \"type\": \"string\",\n        },\n        \"filtering\": {\n            \"name\": _(\"Filtering\"),\n            \"type\": \"bool\",\n            \"default\": False,\n        },\n        \"external_id\": {\n            \"name\": _(\"External ID\"),\n            \"type\": \"string\",\n        },\n        \"status\": {\n            \"name\": _(\"Status\"),\n            \"type\": \"string\",\n        },\n    })\n\n    def __init__(\n        self,\n        secret: str,\n        service: Optional[str] = None,\n        location: Optional[str] = None,\n        alerting_scenario: Optional[str] = None,\n        filtering: Optional[bool] = None,\n        external_id: Optional[str] = None,\n        status: Optional[str] = None,\n        **kwargs: Any\n    ) -> None:\n        \"\"\"\n        Initialize SIGNL4 Object\n        \"\"\"\n        super().__init__(**kwargs)\n\n        # SIGNL4 team or integration secret\n        self.secret = validate_regex(secret)\n        if not self.secret:\n            msg = \"An invalid SIGNL4 team or integration secret \" \\\n                  \"({}) was specified.\".format(secret)\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # A service option for notifications\n        self.service = service\n\n        # A location option for notifications\n        self.location = location\n\n        # A alerting_scenario option for notifications\n        self.alerting_scenario = alerting_scenario\n\n        # A filtering option for notifications\n        self.filtering = (\n            self.template_args[\"filtering\"][\"default\"]\n            if filtering is None\n            else bool(filtering)\n        )\n\n        # A external_id option for notifications\n        self.external_id = external_id\n\n        # A location option for notifications\n        self.status = status\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"\n        Send our SIGNL4 Notification\n        \"\"\"\n\n        # Prepare our headers\n        headers = {\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our persistent_notification.create payload\n        payload = {\n            \"title\": title if title else self.app_desc,\n            \"body\": body,\n            \"X-S4-SourceSystem\": self.app_id,\n        }\n\n        if self.service:\n            payload[\"X-S4-Service\"] = self.service\n\n        if self.alerting_scenario:\n            payload[\"X-S4-AlertingScenario\"] = self.alerting_scenario\n\n        if self.location:\n            payload[\"X-S4-Location\"] = self.location\n\n        if self.filtering:\n            payload[\"X-S4-Filtering\"] = self.filtering\n\n        if self.external_id:\n            payload[\"X-S4-ExternalID\"] = self.external_id\n\n        if self.status:\n            payload[\"X-S4-Status\"] = self.status\n\n        # Prepare our URL\n        notify_url = self.notify_url.format(secret=self.secret)\n\n        self.logger.debug(\n            \"SIGNL4 POST URL: %s (cert_verify=%s)\",\n            notify_url, self.verify_certificate)\n        self.logger.debug(\"SIGNL4 Payload: %r\", payload)\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code not in (\n                    requests.codes.ok, requests.codes.created,\n                    requests.codes.accepted):\n                # We had a problem\n                status_str = \\\n                    NotifySIGNL4.http_response_code_lookup(\n                        r.status_code)\n\n                self.logger.warning(\n                    \"Failed to send SIGNL4 notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code))\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent SIGNL4 notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending SIGNL4 \"\n                \"notification to %s\", self.host)\n            self.logger.debug(\"Socket Exception: %s\", e)\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"\n        Returns all of the identifiers that make this URL unique from\n        another simliar one. Targets or end points should never be identified\n        here.\n        \"\"\"\n        return (\n            self.secure_protocol, self.secret,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"\n        Returns the URL built dynamically based on specified arguments.\n        \"\"\"\n\n        # Define any URL parameters\n        params = {}\n\n        if self.service is not None:\n            params[\"service\"] = self.service\n\n        if self.location is not None:\n            params[\"location\"] = self.location\n\n        if self.alerting_scenario is not None:\n            params[\"alerting_scenario\"] = self.alerting_scenario\n\n        if self.filtering != self.template_args[\"filtering\"][\"default\"]:\n            # Only add filtering if it is not the default value\n            params[\"filtering\"] = \"yes\" if self.filtering else \"no\"\n\n        if self.external_id is not None:\n            params[\"external_id\"] = self.external_id\n\n        if self.status is not None:\n            params[\"status\"] = self.status\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        url = \"{schema}://{secret}\"\n\n        return url.format(\n            schema=self.secure_protocol,\n            # never encode hostname since we're expecting it to be a valid one\n            secret=self.pprint(\n                self.secret, privacy, mode=PrivacyMode.Secret, safe=\"\"),\n            params=NotifySIGNL4.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"\n        Parses the URL and returns enough arguments that can allow\n        us to re-instantiate this object.\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn\"t load the results\n            return results\n\n        # The \"secret\" makes it easier to use yaml configuration\n        if \"secret\" in results[\"qsd\"] and \\\n                len(results[\"qsd\"][\"secret\"]):\n            results[\"secret\"] = \\\n                NotifySIGNL4.unquote(results[\"qsd\"][\"secret\"])\n        else:\n            results[\"secret\"] = \\\n                NotifySIGNL4.unquote(results[\"host\"])\n\n        if \"service\" in results[\"qsd\"] and len(results[\"qsd\"][\"service\"]):\n            results[\"service\"] = \\\n                NotifySIGNL4.unquote(results[\"qsd\"][\"service\"])\n\n        if \"location\" in results[\"qsd\"] and len(results[\"qsd\"][\"location\"]):\n            results[\"location\"] = \\\n                NotifySIGNL4.unquote(results[\"qsd\"][\"location\"])\n\n        if \"alerting_scenario\" in results[\"qsd\"] and \\\n            len(results[\"qsd\"][\"alerting_scenario\"]):\n            results[\"alerting_scenario\"] = \\\n                NotifySIGNL4.unquote(results[\"qsd\"][\"alerting_scenario\"])\n\n        if \"filtering\" in results[\"qsd\"] and len(results[\"qsd\"][\"filtering\"]):\n            results[\"filtering\"] = \\\n                parse_bool(\n                    NotifySIGNL4.unquote(\n                        results[\"qsd\"][\"filtering\"],\n                        NotifySIGNL4.template_args[\"filtering\"][\"default\"]))\n\n        if \"external_id\" in results[\"qsd\"] and \\\n            len(results[\"qsd\"][\"external_id\"]):\n            results[\"external_id\"] = \\\n                NotifySIGNL4.unquote(results[\"qsd\"][\"external_id\"])\n\n        if \"status\" in results[\"qsd\"] and len(results[\"qsd\"][\"status\"]):\n            results[\"status\"] = \\\n                NotifySIGNL4.unquote(results[\"qsd\"][\"status\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/simplepush.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom base64 import urlsafe_b64encode\nimport hashlib\nfrom json import loads\nfrom os import urandom\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\ntry:\n    from cryptography.hazmat.backends import default_backend\n    from cryptography.hazmat.primitives import padding\n    from cryptography.hazmat.primitives.ciphers import (\n        Cipher,\n        algorithms,\n        modes,\n    )\n\n    # We're good to go!\n    NOTIFY_SIMPLEPUSH_ENABLED = True\n\nexcept ImportError:\n    # cryptography is required in order for this package to work\n    NOTIFY_SIMPLEPUSH_ENABLED = False\n\n\nclass NotifySimplePush(NotifyBase):\n    \"\"\"A wrapper for SimplePush Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_SIMPLEPUSH_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"packages_required\": \"cryptography\"\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SimplePush\"\n\n    # The services URL\n    service_url = \"https://simplepush.io/\"\n\n    # The default secure protocol\n    secure_protocol = \"spush\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/simplepush/\"\n\n    # SimplePush uses the http protocol with SimplePush requests\n    notify_url = \"https://api.simplepush.io/send\"\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 10000\n\n    # Defines the maximum allowable characters in the title\n    title_maxlen = 1024\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}\",\n        \"{schema}://{salt}:{password}@{apikey}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            # Used for encrypted logins\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"salt\": {\n                \"name\": _(\"Salt\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"map_to\": \"user\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"event\": {\n                \"name\": _(\"Event\"),\n                \"type\": \"string\",\n            },\n        },\n    )\n\n    def __init__(self, apikey, event=None, **kwargs):\n        \"\"\"Initialize SimplePush Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid SimplePush API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if event:\n            # Event Name (associated with project)\n            self.event = validate_regex(event)\n            if not self.event:\n                msg = (\n                    \"An invalid SimplePush Event Name \"\n                    f\"({event}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        else:\n            # Default Event Name\n            self.event = None\n\n        # Used/cached in _encrypt() function\n        self._iv = None\n        self._iv_hex = None\n        self._key = None\n\n    def _encrypt(self, content):\n        \"\"\"Encrypts message for use with SimplePush.\"\"\"\n\n        if self._iv is None:\n            # initialization vector and cache it\n            self._iv = urandom(algorithms.AES.block_size // 8)\n\n            # convert vector into hex string (used in payload)\n            self._iv_hex = \"\".join([\n                f\"{ord(self._iv[idx:idx + 1]):02x}\"\n                for idx in range(len(self._iv))\n            ]).upper()\n\n            # encrypted key and cache it\n            self._key = bytes(\n                bytearray.fromhex(\n                    hashlib.sha1(\n                        f\"{self.password}{self.user}\".encode()\n                    ).hexdigest()[0:32]\n                )\n            )\n\n        padder = padding.PKCS7(algorithms.AES.block_size).padder()\n        content = padder.update(content.encode()) + padder.finalize()\n        #\n        # Encryption Notice\n        #\n\n        # CBC mode doesn't provide integrity guarantees. Unless the message\n        # authentication for IV and the ciphertext are applied, it will be\n        # vulnerable to a padding oracle attack\n\n        # It is important to identify that both the Apprise package and team\n        # recognizes this AES-CBC-128 weakness but requires that it exists due\n        # to it being the SimplePush Requirement as documented on their\n        # website here https://simplepush.io/features.\n\n        # In the event the website link above does not exist/work, a screen\n        # capture of the reference to the requirement for this encryption\n        # can also be found on the Apprise SimplePush Wiki:\n        #   https://appriseit.com/services/simplepush/\\\n        #        #-aes-cbc-128-encryption-weakness\n        #\n        encryptor = Cipher(\n            algorithms.AES(self._key), modes.CBC(self._iv), default_backend()\n        ).encryptor()\n\n        return urlsafe_b64encode(\n            encryptor.update(content) + encryptor.finalize()\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform SimplePush Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"key\": self.apikey,\n        }\n\n        if self.password and self.user:\n            body = self._encrypt(body)\n            title = self._encrypt(title)\n            payload.update({\n                \"encrypted\": \"true\",\n                \"iv\": self._iv_hex,\n            })\n\n        # prepare SimplePush Object\n        payload.update({\n            \"msg\": body,\n            \"title\": title,\n        })\n\n        if self.event:\n            # Store Event\n            payload[\"event\"] = self.event\n\n        self.logger.debug(\n            \"SimplePush POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"SimplePush Payload: {payload!s}\")\n\n        # We need to rely on the status string returned in the SimplePush\n        # response\n        status_str = None\n        status = None\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            # Get our SimplePush response (if it's possible)\n            try:\n                json_response = loads(r.content)\n                status_str = json_response.get(\"message\")\n                status = json_response.get(\"status\")\n\n            except (TypeError, ValueError, AttributeError):\n                # TypeError = r.content is not a String\n                # ValueError = r.content is Unparsable\n                # AttributeError = r.content is None\n                pass\n\n            if r.status_code != requests.codes.ok or status != \"OK\":\n                # We had a problem\n                status_str = (\n                    status_str\n                    if status_str\n                    else NotifyBase.http_response_code_lookup(r.status_code)\n                )\n\n                self.logger.warning(\n                    \"Failed to send SimplePush notification:\"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent SimplePush notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending SimplePush notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user, self.password, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if self.event:\n            params[\"event\"] = self.event\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{salt}:{password}@\".format(\n                salt=self.pprint(\n                    self.user, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n\n        return \"{schema}://{auth}{apikey}/?{params}\".format(\n            schema=self.secure_protocol,\n            auth=auth,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            params=NotifySimplePush.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Set the API Key\n        results[\"apikey\"] = NotifySimplePush.unquote(results[\"host\"])\n\n        # Event\n        if \"event\" in results[\"qsd\"] and len(results[\"qsd\"][\"event\"]):\n            # Extract the account sid from an argument\n            results[\"event\"] = NotifySimplePush.unquote(\n                results[\"qsd\"][\"event\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/sinch.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this service you will need a Sinch account to which you can get your\n# API_TOKEN and SERVICE_PLAN_ID right from your console/dashboard at:\n#     https://dashboard.sinch.com/sms/overview\n#\n# You will also need to send the SMS From a phone number or account id name.\n\n# This is identified as the source (or where the SMS message will originate\n# from). Activated phone numbers can be found on your dashboard here:\n#  - https://dashboard.sinch.com/numbers/your-numbers/numbers\n#\nimport json\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n\nclass SinchRegion:\n    \"\"\"Defines the Sinch Server Regions.\"\"\"\n\n    USA = \"us\"\n    EUROPE = \"eu\"\n\n\n# Used for verification purposes\nSINCH_REGIONS = (SinchRegion.USA, SinchRegion.EUROPE)\n\n\nclass NotifySinch(NotifyBase):\n    \"\"\"A wrapper for Sinch Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Sinch\"\n\n    # The services URL\n    service_url = \"https://sinch.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"sinch\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # the number of seconds undelivered messages should linger for\n    # in the Sinch queue\n    validity_period = 14400\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/sinch/\"\n\n    # Sinch uses the http protocol with JSON requests\n    #   - the 'spi' gets substituted with the Service Provider ID\n    #     provided as part of the Apprise URL.\n    notify_url = \"https://{region}.sms.api.sinch.com/xms/v1/{spi}/batches\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{service_plan_id}:{api_token}@{from_phone}\",\n        \"{schema}://{service_plan_id}:{api_token}@{from_phone}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"service_plan_id\": {\n                \"name\": _(\"Account SID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-f0-9]+$\", \"i\"),\n            },\n            \"api_token\": {\n                \"name\": _(\"Auth Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-f0-9]+$\", \"i\"),\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"short_code\": {\n                \"name\": _(\"Target Short Code\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[0-9]{5,6}$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"from_phone\",\n            },\n            \"spi\": {\n                \"alias_of\": \"service_plan_id\",\n            },\n            \"region\": {\n                \"name\": _(\"Region\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z]{2}$\", \"i\"),\n                \"default\": SinchRegion.USA,\n            },\n            \"token\": {\n                \"alias_of\": \"api_token\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        service_plan_id,\n        api_token,\n        source,\n        targets=None,\n        region=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Sinch Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # The Account SID associated with the account\n        self.service_plan_id = validate_regex(\n            service_plan_id, *self.template_tokens[\"service_plan_id\"][\"regex\"]\n        )\n        if not self.service_plan_id:\n            msg = (\n                \"An invalid Sinch Account SID \"\n                f\"({service_plan_id}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The Authentication Token associated with the account\n        self.api_token = validate_regex(\n            api_token, *self.template_tokens[\"api_token\"][\"regex\"]\n        )\n        if not self.api_token:\n            msg = (\n                \"An invalid Sinch Authentication Token \"\n                f\"({api_token}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Setup our region\n        self.region = (\n            self.template_args[\"region\"][\"default\"]\n            if not isinstance(region, str)\n            else region.lower()\n        )\n        if self.region and self.region not in SINCH_REGIONS:\n            msg = f\"The region specified ({region}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The Source Phone # and/or short-code\n        result = is_phone_no(source, min_len=5)\n        if not result:\n            msg = (\n                \"The Account (From) Phone # or Short-code specified \"\n                f\"({source}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Tidy source\n        self.source = result[\"full\"]\n\n        if len(self.source) < 11 or len(self.source) > 14:\n            # A short code is a special 5 or 6 digit telephone number\n            # that's shorter than a full phone number.\n            if len(self.source) not in (5, 6):\n                msg = (\n                    \"The Account (From) Phone # specified \"\n                    f\"({source}) is invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            # else... it as a short code so we're okay\n\n        else:\n            # We're dealing with a phone number; so we need to just\n            # place a plus symbol at the end of it\n            self.source = f\"+{self.source}\"\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Parse each phone number we found\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(\"+{}\".format(result[\"full\"]))\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Sinch Notification.\"\"\"\n\n        if not self.targets and len(self.source) in (5, 6):\n            # Generate a warning since we're a short-code.  We need\n            # a number to message at minimum\n            self.logger.warning(\"There are no valid Sinch targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Authorization\": f\"Bearer {self.api_token}\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"body\": body,\n            \"from\": self.source,\n            # The To gets populated in the loop below\n            \"to\": None,\n        }\n\n        # Prepare our Sinch URL (spi = Service Provider ID)\n        url = self.notify_url.format(\n            region=self.region, spi=self.service_plan_id\n        )\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        if len(targets) == 0:\n            # No sources specified, use our own phone no\n            targets.append(self.source)\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our user\n            payload[\"to\"] = [target]\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Sinch POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Sinch Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    url,\n                    data=json.dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # The responsne might look like:\n                # {\n                #  \"id\": \"CJloRJOe3MtDITqx\",\n                #  \"to\": [\"15551112222\"],\n                #  \"from\": \"15553334444\",\n                #  \"canceled\": false,\n                #  \"body\": \"This is a test message from your Sinch account\",\n                #  \"type\": \"mt_text\",\n                #  \"created_at\": \"2020-01-14T01:05:20.694Z\",\n                #  \"modified_at\": \"2020-01-14T01:05:20.694Z\",\n                #  \"delivery_report\": \"none\",\n                #  \"expire_at\": \"2020-01-17T01:05:20.694Z\",\n                #  \"flash_message\": false\n                # }\n                if r.status_code not in (\n                    requests.codes.created,\n                    requests.codes.ok,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    # set up our status code to use\n                    status_code = r.status_code\n\n                    try:\n                        # Update our status response if we can\n                        json_response = json.loads(r.content)\n                        status_code = json_response.get(\"code\", status_code)\n                        status_str = json_response.get(\"message\", status_str)\n\n                    except (AttributeError, TypeError, ValueError):\n                        # ValueError = r.content is Unparsable\n                        # TypeError = r.content is None\n                        # AttributeError = r is None\n\n                        # We could not parse JSON response.\n                        # We will just use the status we already have.\n                        pass\n\n                    self.logger.warning(\n                        \"Failed to send Sinch notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Sinch notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending Sinch:{target} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.service_plan_id,\n            self.api_token,\n            self.source,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"region\": self.region,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{spi}:{token}@{source}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            spi=self.pprint(\n                self.service_plan_id, privacy, mode=PrivacyMode.Tail, safe=\"\"\n            ),\n            token=self.pprint(self.api_token, privacy, safe=\"\"),\n            source=NotifySinch.quote(self.source, safe=\"\"),\n            targets=\"/\".join(\n                [NotifySinch.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifySinch.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifySinch.split_path(results[\"fullpath\"])\n\n        # The hostname is our source number\n        results[\"source\"] = NotifySinch.unquote(results[\"host\"])\n\n        # Get our service_plan_ide and api_token from the user/pass config\n        results[\"service_plan_id\"] = NotifySinch.unquote(results[\"user\"])\n        results[\"api_token\"] = NotifySinch.unquote(results[\"password\"])\n\n        # Auth Token\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Extract the account spi from an argument\n            results[\"api_token\"] = NotifySinch.unquote(results[\"qsd\"][\"token\"])\n\n        # Account SID\n        if \"spi\" in results[\"qsd\"] and len(results[\"qsd\"][\"spi\"]):\n            # Extract the account spi from an argument\n            results[\"service_plan_id\"] = NotifySinch.unquote(\n                results[\"qsd\"][\"spi\"]\n            )\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifySinch.unquote(results[\"qsd\"][\"from\"])\n\n        if \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifySinch.unquote(results[\"qsd\"][\"source\"])\n\n        # Allow one to define a region\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            results[\"region\"] = NotifySinch.unquote(results[\"qsd\"][\"region\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySinch.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/slack.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# There are 2 ways to use this plugin...\n# Method 1: Via Webhook:\n#   Visit https://my.slack.com/services/new/incoming-webhook/\n#   to create a new incoming webhook for your account. You'll need to\n#   follow the wizard to pre-determine the channel(s) you want your\n#   message to broadcast to, and when you're complete, you will\n#   recieve a URL that looks something like this:\n#   https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7\n#                                       ^         ^               ^\n#                                       |         |               |\n#    These are important <--------------^---------^---------------^\n#\n# Method 2: Via a Bot:\n#   1. visit: https://api.slack.com/apps?new_app=1\n#   2. Pick an App Name (such as Apprise) and select your workspace.  Then\n#       press 'Create App'\n#   3. You'll be able to click on 'Bots' from here where you can then choose\n#       to add a 'Bot User'.  Give it a name and choose 'Add Bot User'.\n#   4. Now you can choose 'Install App' to which you can choose 'Install App\n#       to Workspace'.\n#   5. You will need to authorize the app which you get prompted to do.\n#   6. Finally you'll get some important information providing you your\n#      'OAuth Access Token' and 'Bot User OAuth Access Token' such as:\n#        slack://{Oauth Access Token}\n#\n#        ... which might look something like:\n#        slack://xoxp-1234-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d\n#        ... or:\n#        slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d\n#\n#       You must at least give your bot the following access for it to\n#       be useful:\n#         - chat:write       - MUST be set otherwise you can not post into\n#                              a channel\n#         - users:read.email - Required if you want to be able to lookup\n#                              users by their email address.\n#\n#      The easiest way to bring a bot into a channel (so that it can send\n#      a message to it is to invite it. At this time Apprise does not support\n#      an auto-join functionality. To do this:\n#        - In the 'Details' section of your channel\n#        - Click on the 'More' [...] (elipse icon)\n#        - Click 'Add apps'\n#        - You will be able to select the Bot App you previously created\n#        - Your bot will join your channel.\n\nimport contextlib\nfrom json import dumps, loads\nimport re\nfrom time import time\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\nSLACK_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n# Used to break path apart into list of channels\nCHANNEL_LIST_DELIM = re.compile(r\"[ \\t\\r\\n,#\\\\/]+\")\n\n# Channel Regular Expression Parsing\nCHANNEL_RE = re.compile(\n    r\"^(?P<channel>[+#@]?[A-Z0-9_-]{1,32})(:(?P<thread_ts>[0-9.]+))?$\", re.I\n)\n\n\nclass SlackMode:\n    \"\"\"Tracks the mode of which we're using Slack.\"\"\"\n\n    # We're dealing with a webhook\n    # Our token looks like: T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7\n    WEBHOOK = \"hook\"\n\n    # Government Webhook\n    # Our token still looks like: T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7\n    # however we have a different URL we post to\n    WEBHOOK_GOV = \"gov-hook\"\n\n    # We're dealing with a bot (using the OAuth Access Token)\n    # Our token looks like: xoxp-1234-1234-1234-abc124 or\n    # Our token looks like: xoxb-1234-1234-abc124 or\n    BOT = \"bot\"\n\n\n# Define our Slack Modes\nSLACK_MODES = (\n    SlackMode.WEBHOOK,\n    SlackMode.WEBHOOK_GOV,\n    SlackMode.BOT,\n)\n\n\nclass NotifySlack(NotifyBase):\n    \"\"\"A wrapper for Slack Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Slack\"\n\n    # The services URL\n    service_url = \"https://slack.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"slack\"\n\n    # Allow 50 requests per minute (Tier 2).\n    # 60/50 = 0.2\n    request_rate_per_sec = 1.2\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/slack/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # The maximum targets to include when doing batch transfers\n    # Slack Webhook URLs\n    webhook_url = \"https://hooks.slack.com/services\"\n    webhook_gov_url = \"https://hooks.slack-gov.com/services\"\n\n    # Slack API URL (used with Bots)\n    api_url = \"https://slack.com/api/{}\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 35000\n\n    # Default Notification Format\n    notify_format = NotifyFormat.MARKDOWN\n\n    # Bot's do not have default channels to notify; so #general\n    # becomes the default channel in BOT mode\n    default_notification_channel = \"#general\"\n\n    # Define object templates\n    templates = (\n        # Webhook\n        \"{schema}://{token_a}/{token_b}/{token_c}\",\n        \"{schema}://{botname}@{token_a}/{token_b}/{token_c}\",\n        \"{schema}://{token_a}/{token_b}/{token_c}/{targets}\",\n        \"{schema}://{botname}@{token_a}/{token_b}/{token_c}/{targets}\",\n        # Bot\n        \"{schema}://{access_token}/\",\n        \"{schema}://{access_token}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"botname\": {\n                \"name\": _(\"Bot Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"user\",\n            },\n            # Bot User OAuth Access Token\n            # which always starts with xoxp- e.g.:\n            #     xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d\n            \"access_token\": {\n                \"name\": _(\"OAuth Access Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^(?:xoxe\\.)?xox[abp]-[A-Z0-9-]+$\", \"i\"),\n            },\n            # Token required as part of the Webhook request\n            #  /AAAAAAAAA/........./........................\n            \"token_a\": {\n                \"name\": _(\"Token A\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9]+$\", \"i\"),\n            },\n            # Token required as part of the Webhook request\n            #  /........./BBBBBBBBB/........................\n            \"token_b\": {\n                \"name\": _(\"Token B\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9]+$\", \"i\"),\n            },\n            # Token required as part of the Webhook request\n            #  /........./........./CCCCCCCCCCCCCCCCCCCCCCCC\n            \"token_c\": {\n                \"name\": _(\"Token C\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Za-z0-9]+$\", \"i\"),\n            },\n            \"target_encoded_id\": {\n                \"name\": _(\"Target Encoded ID\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"map_to\": \"targets\",\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"target_channels\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"footer\": {\n                \"name\": _(\"Include Footer\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_footer\",\n            },\n            # Use Payload in Blocks (vs legacy way):\n            #  See: https://api.slack.com/reference/messaging/payload\n            \"blocks\": {\n                \"name\": _(\"Use Blocks\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"use_blocks\",\n            },\n            \"timestamp\": {\n                \"name\": _(\"Include Timestamp\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_timestamp\",\n            },\n            \"mode\": {\n                \"name\": _(\"Message Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": SLACK_MODES,\n                # mode is detected if not specified\n            },\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"alias_of\": (\"access_token\", \"token_a\", \"token_b\", \"token_c\"),\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    # Formatting requirements are defined here:\n    # https://api.slack.com/docs/message-formatting\n    _re_formatting_map = {\n        # New lines must become the string version\n        r\"\\r\\*\\n\": \"\\\\n\",\n        # Escape other special characters\n        r\"&\": \"&amp;\",\n        r\"<\": \"&lt;\",\n        r\">\": \"&gt;\",\n    }\n\n    # To notify a channel, one uses <!channel|channel>\n    _re_channel_support = re.compile(\n        r\"(?P<match>(?:<|\\&lt;)?[ \\t]*\"\n        r\"!(?P<channel>[^| \\n]+)\"\n        r\"(?:[ \\t]*\\|[ \\t]*(?:(?P<val>[^\\n]+?)[ \\t]*)?(?:>|\\&gt;)\"\n        r\"|(?:>|\\&gt;)))\",\n        re.IGNORECASE,\n    )\n\n    # To notify a user by their ID, one uses <@U6TTX1F9R>\n    _re_user_id_support = re.compile(\n        r\"(?P<match>(?:<|\\&lt;)?[ \\t]*\"\n        r\"@(?P<userid>[^| \\n]+)\"\n        r\"(?:[ \\t]*\\|[ \\t]*(?:(?P<val>[^\\n]+?)[ \\t]*)?(?:>|\\&gt;)\"\n        r\"|(?:>|\\&gt;)))\",\n        re.IGNORECASE,\n    )\n\n    # The markdown in slack isn't [desc](url), it's <url|desc>\n    #\n    # To accommodate this, we need to ensure we don't escape URLs that match\n    _re_url_support = re.compile(\n        r\"(?P<match>(?:<|\\&lt;)?[ \\t]*\"\n        r\"(?P<url>(?:https?|mailto)://[^| \\n]+)\"\n        r\"(?:[ \\t]*\\|[ \\t]*(?:(?P<val>[^\\n]+?)[ \\t]*)?(?:>|\\&gt;)\"\n        r\"|(?:>|\\&gt;)))\",\n        re.IGNORECASE,\n    )\n\n    def __init__(\n        self,\n        access_token=None,\n        token_a=None,\n        token_b=None,\n        token_c=None,\n        targets=None,\n        include_image=None,\n        include_footer=None,\n        include_timestamp=None,\n        use_blocks=None,\n        mode=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Slack Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Store our webhook mode\n        if mode and isinstance(mode, str):\n            self.mode = next(\n                (a for a in SLACK_MODES if a.startswith(mode)), None\n            )\n            if self.mode not in SLACK_MODES:\n                msg = (\n                    f\"The Slack mode specified ({mode}) is invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        else:  # Detect\n            self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK\n\n        if self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV):\n            self.access_token = None\n            self.token_a = validate_regex(\n                token_a, *self.template_tokens[\"token_a\"][\"regex\"]\n            )\n            if not self.token_a:\n                msg = (\n                    \"An invalid Slack (first) Token \"\n                    f\"({token_a}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            self.token_b = validate_regex(\n                token_b, *self.template_tokens[\"token_b\"][\"regex\"]\n            )\n            if not self.token_b:\n                msg = (\n                    \"An invalid Slack (second) Token \"\n                    f\"({token_b}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            self.token_c = validate_regex(\n                token_c, *self.template_tokens[\"token_c\"][\"regex\"]\n            )\n            if not self.token_c:\n                msg = (\n                    \"An invalid Slack (third) Token \"\n                    f\"({token_c}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.token_a = None\n            self.token_b = None\n            self.token_c = None\n            self.access_token = validate_regex(\n                access_token, *self.template_tokens[\"access_token\"][\"regex\"]\n            )\n            if not self.access_token:\n                msg = (\n                    \"An invalid Slack OAuth Access Token \"\n                    f\"({access_token}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        # Look the users up by their email address and map them back to their\n        # id here for future queries (if needed). This allows people to\n        # specify a full email as a recipient via slack\n        self._lookup_users = {}\n\n        self.use_blocks = (\n            parse_bool(use_blocks, self.template_args[\"blocks\"][\"default\"])\n            if use_blocks is not None\n            else self.template_args[\"blocks\"][\"default\"]\n        )\n\n        # Build list of channels\n        self.channels = parse_list(targets)\n        if len(self.channels) == 0:\n            # No problem; the webhook is smart enough to just notify the\n            # channel it was created for; adding 'None' is just used as\n            # a flag lower to not set the channels\n            self.channels.append(\n                None\n                if self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV)\n                else self.default_notification_channel\n            )\n\n        # Iterate over above list and store content accordingly\n        self._re_formatting_rules = re.compile(\n            r\"(\" + \"|\".join(self._re_formatting_map.keys()) + r\")\",\n            re.IGNORECASE,\n        )\n        # Place a thumbnail image inline with the message body\n        self.include_image = \\\n            self.template_args[\"image\"][\"default\"] \\\n            if include_image is None else include_image\n\n        # Place a footer with each post\n        self.include_footer = \\\n            self.template_args[\"footer\"][\"default\"] \\\n            if include_footer is None else include_footer\n\n        # timestamp inclusion (only applicable if footer also defined\n        self.include_timestamp = \\\n            self.template_args[\"timestamp\"][\"default\"] \\\n            if include_timestamp is None \\\n            else include_timestamp\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Slack Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        #\n        # Prepare JSON Object (applicable to both WEBHOOK and BOT mode)\n        #\n        if self.use_blocks:\n            # Our slack format\n            slack_format = (\n                \"mrkdwn\"\n                if self.notify_format == NotifyFormat.MARKDOWN\n                else \"plain_text\"\n            )\n\n            payload = {\n                \"username\": self.user if self.user else self.app_id,\n                \"attachments\": [{\n                    \"blocks\": [{\n                        \"type\": \"section\",\n                        \"text\": {\"type\": slack_format, \"text\": body},\n                    }],\n                    \"color\": self.color(notify_type),\n                }],\n            }\n\n            # Slack only accepts non-empty header sections\n            if title:\n                payload[\"attachments\"][0][\"blocks\"].insert(\n                    0,\n                    {\n                        \"type\": \"header\",\n                        \"text\": {\n                            \"type\": \"plain_text\",\n                            \"text\": title,\n                            \"emoji\": True,\n                        },\n                    },\n                )\n\n            # Include the footer only if specified to do so\n            if self.include_footer:\n\n                # Acquire our to-be footer icon if configured to do so\n                image_url = (\n                    None\n                    if not self.include_image\n                    else self.image_url(notify_type)\n                )\n\n                # Prepare our footer based on the block structure\n                footer = {\n                    \"type\": \"context\",\n                    \"elements\": [{\"type\": slack_format, \"text\": self.app_id}],\n                }\n\n                if image_url:\n                    payload[\"icon_url\"] = image_url\n\n                    footer[\"elements\"].insert(\n                        0,\n                        {\n                            \"type\": \"image\",\n                            \"image_url\": image_url,\n                            \"alt_text\": notify_type,\n                        },\n                    )\n\n                payload[\"attachments\"][0][\"blocks\"].append(footer)\n\n        else:\n            #\n            # Legacy API Formatting\n            #\n            if self.notify_format == NotifyFormat.MARKDOWN:\n                body = self._re_formatting_rules.sub(  # pragma: no branch\n                    lambda x: self._re_formatting_map[x.group()],\n                    body,\n                )\n\n                # Support <!channel|desc>, <!channel> entries\n                for match in self._re_channel_support.findall(body):\n                    # Swap back any ampersands previously updaated\n                    channel = match[1].strip()\n                    desc = match[2].strip()\n\n                    # Update our string\n                    body = re.sub(\n                        re.escape(match[0]),\n                        f\"<!{channel}|{desc}>\" if desc else f\"<!{channel}>\",\n                        body,\n                        flags=re.IGNORECASE,\n                    )\n\n                # Support <@userid|desc>, <@channel> entries\n                for match in self._re_user_id_support.findall(body):\n                    # Swap back any ampersands previously updaated\n                    user = match[1].strip()\n                    desc = match[2].strip()\n\n                    # Update our string\n                    body = re.sub(\n                        re.escape(match[0]),\n                        f\"<@{user}|{desc}>\" if desc else f\"<@{user}>\",\n                        body,\n                        flags=re.IGNORECASE,\n                    )\n\n                # Support <url|desc>, <url> entries\n                for match in self._re_url_support.findall(body):\n                    # Swap back any ampersands previously updaated\n                    url = match[1].replace(\"&amp;\", \"&\")\n                    desc = match[2].strip()\n\n                    # Update our string\n                    body = re.sub(\n                        re.escape(match[0]),\n                        f\"<{url}|{desc}>\" if desc else f\"<{url}>\",\n                        body,\n                        flags=re.IGNORECASE,\n                    )\n\n            # Perform Formatting on title here; this is not needed for block\n            # mode above\n            title = self._re_formatting_rules.sub(  # pragma: no branch\n                lambda x: self._re_formatting_map[x.group()],\n                title,\n            )\n\n            # Prepare JSON Object (applicable to both WEBHOOK and BOT mode)\n            payload = {\n                \"username\": self.user if self.user else self.app_id,\n                # Use Markdown language\n                \"mrkdwn\": self.notify_format == NotifyFormat.MARKDOWN,\n                \"attachments\": [{\n                    \"title\": title,\n                    \"text\": body,\n                    \"color\": self.color(notify_type),\n                }],\n            }\n\n            # Acquire our to-be footer icon if configured to do so\n            image_url = (\n                None if not self.include_image else self.image_url(notify_type)\n            )\n\n            if image_url:\n                payload[\"icon_url\"] = image_url\n\n            # Include the footer only if specified to do so\n            if self.include_footer:\n                if image_url:\n                    payload[\"attachments\"][0][\"footer_icon\"] = image_url\n\n                # Include the footer only if specified to do so\n                payload[\"attachments\"][0][\"footer\"] = self.app_id\n\n                if self.include_timestamp:\n                    # Timestamp\n                    payload[\"attachments\"][0][\"ts\"] = time()\n        if (\n            attach\n            and self.attachment_support\n            and self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV)\n        ):\n            # Be friendly; let the user know why they can't send their\n            # attachments if using the Webhook mode\n            self.logger.warning(\"Slack Webhooks do not support attachments.\")\n\n        # Prepare our Slack URL (depends on mode)\n        if self.mode is SlackMode.WEBHOOK:\n            url = (\n                f\"{self.webhook_url}/{self.token_a}\"\n                f\"/{self.token_b}/{self.token_c}\"\n            )\n\n        elif self.mode is SlackMode.WEBHOOK_GOV:\n            url = (\n                f\"{self.webhook_gov_url}/{self.token_a}\"\n                f\"/{self.token_b}/{self.token_c}\"\n            )\n\n        else:  # SlackMode.BOT\n            url = self.api_url.format(\"chat.postMessage\")\n\n        # Create a copy of the channel list\n        channels = list(self.channels)\n\n        attach_channel_list = []\n        while len(channels):\n            channel = channels.pop(0)\n            if channel is not None:\n                # We'll perform a user lookup if we detect an email\n                email = is_email(channel)\n                if email:\n                    payload[\"channel\"] = self.lookup_userid(\n                        email[\"full_email\"]\n                    )\n\n                    if not payload[\"channel\"]:\n                        # Move along; any notifications/logging would have\n                        # come from lookup_userid()\n                        has_error = True\n                        continue\n\n                else:  # Channel\n                    result = CHANNEL_RE.match(channel)\n\n                    if not result:\n                        # Channel over-ride was specified\n                        self.logger.warning(\n                            f\"The specified Slack target {channel} is invalid;\"\n                            \"skipping.\"\n                        )\n\n                        # Mark our failure\n                        has_error = True\n                        continue\n\n                    # Store oure content\n                    channel, thread_ts = result.group(\"channel\"), result.group(\n                        \"thread_ts\"\n                    )\n                    if thread_ts:\n                        payload[\"thread_ts\"] = thread_ts\n\n                    elif \"thread_ts\" in payload:\n                        # Handle situations where one channel has a thread_id\n                        # specified, and the next does not.  We do not want to\n                        # cary forward the last value specified\n                        del payload[\"thread_ts\"]\n\n                    if channel[0] == \"+\":\n                        # Treat as encoded id if prefixed with a +\n                        payload[\"channel\"] = channel[1:]\n\n                    elif channel[0] == \"@\":\n                        # Treat @ value 'as is'\n                        payload[\"channel\"] = channel\n\n                    else:\n                        # Prefix with channel hash tag (if not already)\n                        payload[\"channel\"] = (\n                            channel if channel[0] == \"#\" else f\"#{channel}\"\n                        )\n\n            response = self._send(url, payload)\n            if not response:\n                # Handle any error\n                has_error = True\n                continue\n\n            # Store the valid channel or chat ID (for DMs) that will\n            # be accepted by Slack's attachment method later.\n            if response.get(\"channel\"):\n                attach_channel_list.append(response.get(\"channel\"))\n\n            self.logger.info(\n                \"Sent Slack notification{}.\".format(\n                    f\" to {channel}\" if channel is not None else \"\"\n                )\n            )\n\n        if (\n            attach\n            and self.attachment_support\n            and self.mode is SlackMode.BOT\n            and attach_channel_list\n        ):\n            # Send our attachments (can only be done in bot mode)\n            for no, attachment in enumerate(attach, start=1):\n\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    f\"Posting Slack attachment {attachment.url(privacy=True)}\"\n                )\n\n                # Get the URL to which to upload the file.\n                # https://api.slack.com/methods/files.getUploadURLExternal\n                params = {\n                    \"filename\": (\n                        attachment.name\n                        if attachment.name\n                        else f\"file{no:03}.dat\"\n                    ),\n                    \"length\": len(attachment),\n                }\n                url_ = self.api_url.format(\"files.getUploadURLExternal\")\n                response = self._send(\n                    url_, {}, http_method=\"get\", params=params\n                )\n                if not (\n                    response\n                    and response.get(\"file_id\")\n                    and response.get(\"upload_url\")\n                ):\n                    self.logger.error(\"Could retrieve file upload URL.\")\n                    # We failed to get an upload URL, take an early exit\n                    return False\n\n                file_id = response.get(\"file_id\")\n                upload_url = response.get(\"upload_url\")\n\n                # Upload file\n                response = self._send(upload_url, {}, attach=attachment)\n\n                # Send file to channels\n                # https://api.slack.com/methods/files.completeUploadExternal\n                for channel_id in attach_channel_list:\n                    payload_ = {\n                        \"files\": [{\n                            \"id\": file_id,\n                            \"title\": attachment.name,\n                        }],\n                        \"channel_id\": channel_id,\n                    }\n                    url_ = self.api_url.format(\"files.completeUploadExternal\")\n                    response = self._send(url_, payload_)\n                    # Expected response\n                    # {\n                    #     \"ok\": true,\n                    #     \"files\": [\n                    #         {\n                    #             \"id\": \"F123ABC456\",\n                    #             \"title\": \"slack-test\"\n                    #         }\n                    #     ]\n                    # }\n                    if not (response and response.get(\"files\")):\n                        self.logger.error(\"Failed to send file to channel.\")\n                        # We failed to send the file to the channel,\n                        # take an early exit\n                        return False\n\n        return not has_error\n\n    def lookup_userid(self, email):\n        \"\"\"Takes an email address and attempts to resolve/acquire it's user id\n        for notification purposes.\"\"\"\n        if email in self._lookup_users:\n            # We're done as entry has already been retrieved\n            return self._lookup_users[email]\n\n        if self.mode is not SlackMode.BOT:\n            # You can not look up\n            self.logger.warning(\n                \"Emails can not be resolved to Slack User IDs unless you \"\n                \"have a bot configured.\"\n            )\n            return None\n\n        lookup_url = self.api_url.format(\"users.lookupByEmail\")\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n            \"Authorization\": f\"Bearer {self.access_token}\",\n        }\n\n        # we pass in our email address as the argument\n        params = {\n            \"email\": email,\n        }\n\n        self.logger.debug(\n            \"Slack User Lookup POST URL:\"\n            f\" {lookup_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Slack User Lookup Parameters: {params!s}\")\n\n        # Initialize our HTTP JSON response\n        response = {\"ok\": False}\n\n        # Initialize our detected user id (also the response to this function)\n        user_id = None\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.get(\n                lookup_url,\n                headers=headers,\n                params=params,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            # Attachment posts return a JSON string\n            with contextlib.suppress(AttributeError, TypeError, ValueError):\n                # Load our JSON object if we can\n\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                response = loads(r.content)\n\n            # We can get a 200 response, but still fail.  A failure message\n            # might look like this (missing bot permissions):\n            #    {\n            #      'ok': False,\n            #      'error': 'missing_scope',\n            #      'needed': 'users:read.email',\n            #      'provided': 'calls:write,chat:write'\n            #    }\n\n            if r.status_code != requests.codes.ok or not (\n                response and response.get(\"ok\", False)\n            ):\n\n                # We had a problem\n                status_str = NotifySlack.http_response_code_lookup(\n                    r.status_code, SLACK_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send Slack User Lookup:{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            # If we reach here, then we were successful in looking up\n            # the user. A response generally looks like this:\n            # {\n            #   'ok': True,\n            #   'user': {\n            #     'id': 'J1ZQB9T9Y',\n            #     'team_id': 'K1WR6TML2',\n            #     'name': 'l2g',\n            #     'deleted': False,\n            #     'color': '9f69e7',\n            #     'real_name': 'Chris C',\n            #     'tz': 'America/New_York',\n            #     'tz_label': 'Eastern Standard Time',\n            #     'tz_offset': -18000,\n            #     'profile': {\n            #       'title': '',\n            #       'phone': '',\n            #       'skype': '',\n            #       'real_name': 'Chris C',\n            #       'real_name_normalized':\n            #       'Chris C',\n            #       'display_name': 'l2g',\n            #       'display_name_normalized': 'l2g',\n            #       'fields': None,\n            #       'status_text': '',\n            #       'status_emoji': '',\n            #       'status_expiration': 0,\n            #       'avatar_hash': 'g785e9c0ddf6',\n            #       'email': 'lead2gold@gmail.com',\n            #       'first_name': 'Chris',\n            #       'last_name': 'C',\n            #       'image_24': 'https://secure.gravatar.com/...',\n            #       'image_32': 'https://secure.gravatar.com/...',\n            #       'image_48': 'https://secure.gravatar.com/...',\n            #       'image_72': 'https://secure.gravatar.com/...',\n            #       'image_192': 'https://secure.gravatar.com/...',\n            #       'image_512': 'https://secure.gravatar.com/...',\n            #       'status_text_canonical': '',\n            #       'team': 'K1WR6TML2'\n            #     },\n            #     'is_admin': True,\n            #     'is_owner': True,\n            #     'is_primary_owner': True,\n            #     'is_restricted': False,\n            #     'is_ultra_restricted': False,\n            #     'is_bot': False,\n            #     'is_app_user': False,\n            #     'updated': 1603904274\n            #   }\n            # }\n            # We're only interested in the id\n            user_id = response[\"user\"][\"id\"]\n\n            # Cache it for future\n            self._lookup_users[email] = user_id\n            self.logger.info(\n                \"Email %s resolves to the Slack User ID: %s.\", email, user_id\n            )\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred looking up Slack User.\",\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            # Return; we're done\n            return None\n\n        return user_id\n\n    def _send(\n        self,\n        url,\n        payload,\n        attach=None,\n        http_method=\"post\",\n        params=None,\n        **kwargs,\n    ):\n        \"\"\"Wrapper to the requests (post) object.\"\"\"\n        self.logger.debug(\n            f\"Slack POST URL: {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Slack Payload: {payload!s}\")\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n        }\n\n        if not attach:\n            headers[\"Content-Type\"] = \"application/json; charset=utf-8\"\n\n        if self.mode is SlackMode.BOT:\n            headers[\"Authorization\"] = f\"Bearer {self.access_token}\"\n\n        # Our response object\n        response = {\"ok\": False}\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        # Our attachment path (if specified)\n        files = None\n\n        try:\n            # Open our attachment path if required:\n            if attach:\n                files = {\n                    \"file\": (\n                        attach.name,\n                        # file handle is safely closed in `finally`; inline\n                        # open is intentional\n                        open(attach.path, \"rb\"),  # noqa: SIM115\n                        ),\n                    }\n\n            r = requests.request(\n                http_method,\n                url,\n                data=payload if attach else dumps(payload),\n                headers=headers,\n                files=files,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n                params=params if params else None,\n            )\n\n            # Posts return a JSON string\n            with contextlib.suppress(AttributeError, TypeError, ValueError):\n                # Load our JSON object if we can\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                response = loads(r.content)\n\n            # Another response type is:\n            # {\n            #   'ok': False,\n            #   'error': 'not_in_channel',\n            # }\n            status_okay = False\n            if self.mode is SlackMode.BOT:\n                status_okay = (\n                    (response and response.get(\"ok\", False))\n                    or\n                    # Responses for file uploads look like this\n                    # 'OK - <file length>'\n                    (\n                        r.content\n                        and isinstance(r.content, bytes)\n                        and b\"OK\" in r.content\n                    )\n                )\n            elif r.content == b\"ok\":\n                # The text 'ok' is returned if this is a Webhook request\n                # So the below captures that as well.\n                status_okay = True\n\n            if r.status_code != requests.codes.ok or not status_okay:\n                # We had a problem\n                status_str = NotifySlack.http_response_code_lookup(\n                    r.status_code, SLACK_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send{} to Slack: {}{}error={}.\".format(\n                        (\" \" + attach.name) if attach else \"\",\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n                return False\n\n            # Message Post Response looks like this:\n            # {\n            #   \"attachments\": [\n            #     {\n            #       \"color\": \"3AA3E3\",\n            #       \"fallback\": \"test\",\n            #       \"id\": 1,\n            #       \"text\": \"my body\",\n            #       \"title\": \"my title\",\n            #       \"ts\": 1573694687\n            #     }\n            #   ],\n            #   \"bot_id\": \"BAK4K23G5\",\n            #   \"icons\": {\n            #     \"image_48\": \"https://s3-us-west-2.amazonaws.com/...\n            #   },\n            #   \"subtype\": \"bot_message\",\n            #   \"text\": \"\",\n            #   \"ts\": \"1573694689.003700\",\n            #   \"type\": \"message\",\n            #   \"username\": \"Apprise\"\n            # }\n\n            # files.completeUploadExternal responses look like this:\n            # {\n            #     \"ok\": true,\n            #     \"files\": [\n            #         {\n            #             \"id\": \"F123ABC456\",\n            #             \"title\": \"slack-test\"\n            #         }\n            #     ]\n            # }\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred posting {}to Slack.\".format(\n                    attach.name if attach else \"\"\n                )\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return False\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while reading {}.\".format(\n                    attach.name if attach else \"attachment\"\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return False\n\n        finally:\n            # Close our file (if it's open) stored in the second element\n            # of our files tuple (index 1)\n            if files:\n                files[\"file\"][1].close()\n\n        # Return the response for processing\n        return response\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.token_a,\n            self.token_b,\n            self.token_c,\n            self.access_token,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"footer\": \"yes\" if self.include_footer else \"no\",\n            \"timestamp\": \"yes\" if self.include_timestamp else \"no\",\n            \"blocks\": \"yes\" if self.use_blocks else \"no\",\n            \"mode\": self.mode,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Determine if there is a botname present\n        botname = \"\"\n        if self.user:\n            botname = \"{botname}@\".format(\n                botname=NotifySlack.quote(self.user, safe=\"\"),\n            )\n\n        if self.mode in (SlackMode.WEBHOOK, SlackMode.WEBHOOK_GOV):\n            return (\n                \"{schema}://{botname}{token_a}/{token_b}/{token_c}/\"\n                \"{targets}/?{params}\".format(\n                    schema=self.secure_protocol,\n                    botname=botname,\n                    token_a=self.pprint(self.token_a, privacy, safe=\"\"),\n                    token_b=self.pprint(self.token_b, privacy, safe=\"\"),\n                    token_c=self.pprint(self.token_c, privacy, safe=\"\"),\n                    targets=\"/\".join(\n                        [NotifySlack.quote(x, safe=\"\") for x in self.channels]\n                    ),\n                    params=NotifySlack.urlencode(params),\n                )\n            )\n        # else -> self.mode == SlackMode.BOT:\n        return \"{schema}://{botname}{access_token}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            botname=botname,\n            access_token=self.pprint(self.access_token, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifySlack.quote(x, safe=\"\") for x in self.channels]\n            ),\n            params=NotifySlack.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.channels)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The first token is stored in the hostname\n        token = NotifySlack.unquote(results[\"host\"])\n\n        # Get unquoted entries\n        entries = NotifySlack.split_path(results[\"fullpath\"])\n\n        # Verify if our token_a us a bot token or part of a webhook:\n        if token.startswith(\"xo\"):\n            # We're dealing with a bot\n            results[\"access_token\"] = token\n\n        else:\n            # We're dealing with a webhook\n            results[\"token_a\"] = token\n            results[\"token_b\"] = entries.pop(0) if entries else None\n            results[\"token_c\"] = entries.pop(0) if entries else None\n\n        # assign remaining entries to the channels we wish to notify\n        results[\"targets\"] = entries\n\n        # Support the token flag where you can set it to the bot token\n        # or the webhook token (with slash delimiters)\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Break our entries up into a list; we can ue the Channel\n            # list delimiter above since it doesn't contain any characters\n            # we don't otherwise accept anyway in our token\n            entries = list(\n                filter(\n                    bool,\n                    CHANNEL_LIST_DELIM.split(\n                        NotifySlack.unquote(results[\"qsd\"][\"token\"])\n                    ),\n                )\n            )\n\n            # check to see if we're dealing with a bot/user token\n            if entries and entries[0].startswith(\"xo\"):\n                # We're dealing with a bot\n                results[\"access_token\"] = entries[0]\n                results[\"token_a\"] = None\n                results[\"token_b\"] = None\n                results[\"token_c\"] = None\n\n            else:  # Webhook\n                results[\"access_token\"] = None\n                results[\"token_a\"] = entries.pop(0) if entries else None\n                results[\"token_b\"] = entries.pop(0) if entries else None\n                results[\"token_c\"] = entries.pop(0) if entries else None\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += list(\n                filter(\n                    bool,\n                    CHANNEL_LIST_DELIM.split(\n                        NotifySlack.unquote(results[\"qsd\"][\"to\"])\n                    ),\n                )\n            )\n\n        # Get Image Flag\n        results[\"include_image\"] = \\\n            parse_bool(results[\"qsd\"].get(\n                \"image\", NotifySlack.template_args[\"image\"][\"default\"]))\n\n        results[\"include_timestamp\"] = \\\n            parse_bool(results[\"qsd\"].get(\n                \"timestamp\",\n                NotifySlack.template_args[\"timestamp\"][\"default\"]))\n\n        # Get Payload structure (use blocks?)\n        if \"blocks\" in results[\"qsd\"] and len(results[\"qsd\"][\"blocks\"]):\n            results[\"use_blocks\"] = parse_bool(results[\"qsd\"][\"blocks\"])\n\n        # Get Footer Flag\n        results[\"include_footer\"] = \\\n            parse_bool(results[\"qsd\"].get(\n                \"footer\", NotifySlack.template_args[\"footer\"][\"default\"]))\n\n        # Get Mode\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            results[\"mode\"] = NotifySlack.unquote(results[\"qsd\"][\"mode\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Supports:\n          - https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C\n          - https://hooks.slack-gov.com/services/TOKEN_A/TOKEN_B/TOKEN_C\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://(?P<host>hooks\\.slack(?P<gov>-gov)?\\.com)/services/\"\n            r\"(?P<token_a>[A-Z0-9]+)/\"\n            r\"(?P<token_b>[A-Z0-9]+)/\"\n            r\"(?P<token_c>[A-Z0-9]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            params = (\n                \"\"\n                if not result.group(\"params\")\n                else result.group(\"params\")\n            )\n\n            if result.group(\"gov\"):\n                # provide gov parameters\n                params = (\"?\" if not params else \"&\") + \\\n                    f\"mode={SlackMode.WEBHOOK_GOV}\"\n\n            return NotifySlack.parse_url(\n                \"{schema}://{token_a}/{token_b}/{token_c}/{params}\".format(\n                    schema=NotifySlack.secure_protocol,\n                    token_a=result.group(\"token_a\"),\n                    token_b=result.group(\"token_b\"),\n                    token_c=result.group(\"token_c\"),\n                    params=params,\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/smpp.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom itertools import chain\n\ntry:\n    import smpplib\n    import smpplib.consts\n    import smpplib.gsm\n\n    # We're good to go!\n    NOTIFY_SMPP_ENABLED = True\n\nexcept ImportError:\n    # cryptography is required in order for this package to work\n    NOTIFY_SMPP_ENABLED = False\n\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_phone_no, parse_phone_no\nfrom .base import NotifyBase\n\n\nclass NotifySMPP(NotifyBase):\n    \"\"\"A wrapper for SMPP Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_SMPP_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"packages_required\": \"smpplib\"\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"SMPP\")\n\n    # The services URL\n    service_url = \"https://smpp.org/\"\n\n    # The default protocol\n    protocol = \"smpp\"\n\n    # The default secure protocol\n    secure_protocol = \"smpps\"\n\n    # Default port setup\n    default_port = 2775\n    default_secure_port = 3550\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/smpp/\"\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    templates = (\n        \"{schema}://{user}:{password}@{host}/{from_phone}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{from_phone}/{targets}\",\n    )\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"host\": {\n                \"name\": _(\"Host\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"required\": True,\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    def __init__(self, source=None, targets=None, **kwargs):\n        \"\"\"Initialize SMPP Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        self.source = None\n\n        if not (self.user and self.password):\n            msg = \"No SMPP user/pass combination was provided\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_phone_no(source)\n        if not result:\n            msg = (\n                f\"The Account (From) Phone # specified ({source}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Tidy source\n        self.source = result[\"full\"]\n\n        # Used for URL generation afterwards only\n        self._invalid_targets = []\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets, prefix=True):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                self._invalid_targets.append(target)\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all the identifiers that make this URL unique from another\n        similar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n            self.source,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return (\n            \"{schema}://{user}:{password}@{host}/{source}/{targets}/?{params}\"\n        ).format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            user=self.user,\n            password=self.password,\n            host=f\"{self.host}:{self.port}\" if self.port else self.host,\n            source=self.source,\n            targets=\"/\".join([\n                NotifySMPP.quote(t, safe=\"\")\n                for t in chain(self.targets, self._invalid_targets)\n            ]),\n            params=self.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\n\n        Always return 1 at least\n        \"\"\"\n        return len(self.targets) if self.targets else 1\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform SMPP Notification.\"\"\"\n\n        if not self.targets:\n            # There were no targets to notify\n            self.logger.warning(\"There were no SMPP targets to notify\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        port = (\n            self.default_port if not self.secure else self.default_secure_port\n        )\n\n        client = smpplib.client.Client(\n            self.host, port, allow_unknown_opt_params=True\n        )\n        try:\n            client.connect()\n            client.bind_transmitter(\n                system_id=self.user, password=self.password\n            )\n\n        except smpplib.exceptions.ConnectionError as e:\n            self.logger.warning(\n                \"Failed to establish connection to SMPP server\"\n                f\" {self.host}: {e}\"\n            )\n            return False\n\n        for target in self.targets:\n            parts, encoding, msg_type = smpplib.gsm.make_parts(body)\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                for payload in parts:\n                    client.send_message(\n                        source_addr_ton=smpplib.consts.SMPP_TON_INTL,\n                        source_addr=self.source,\n                        dest_addr_ton=smpplib.consts.SMPP_TON_INTL,\n                        destination_addr=target,\n                        short_message=payload,\n                        data_coding=encoding,\n                        esm_class=msg_type,\n                        registered_delivery=True,\n                    )\n            except Exception as e:\n                self.logger.warning(f\"Failed to send SMPP notification: {e}\")\n                # Mark our failure\n                has_error = True\n                continue\n\n            self.logger.info(\"Sent SMPP notification to %s\", target)\n\n        client.unbind()\n        client.disconnect()\n        return not has_error\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifySMPP.unquote(results[\"qsd\"][\"from\"])\n\n            # hostname will also be a target in this case\n            results[\"targets\"] = [\n                *NotifySMPP.parse_phone_no(results[\"host\"]),\n                *NotifySMPP.split_path(results[\"fullpath\"]),\n            ]\n\n        else:\n            # store our source\n            results[\"source\"] = NotifySMPP.unquote(results[\"host\"])\n\n            # store targets\n            results[\"targets\"] = NotifySMPP.split_path(results[\"fullpath\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySMPP.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        results[\"targets\"] = NotifySMPP.split_path(results[\"fullpath\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySMPP.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # store any additional payload extras defined\n        results[\"payload\"] = {\n            NotifySMPP.unquote(x): NotifySMPP.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        # Add our GET parameters in the event the user wants to pass them\n        results[\"params\"] = {\n            NotifySMPP.unquote(x): NotifySMPP.unquote(y)\n            for x, y in results[\"qsd-\"].items()\n        }\n\n        # Support the 'from' and 'source' variable so that we can support\n        # targets this way too.\n        # 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifySMPP.unquote(results[\"qsd\"][\"from\"])\n        elif results[\"targets\"]:\n            # from phone number is the first entry in the list otherwise\n            results[\"source\"] = results[\"targets\"].pop(0)\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/smseagle.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom itertools import chain\nfrom json import dumps, loads\nimport logging\nimport re\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_bool,\n    parse_phone_no,\n    validate_regex,\n)\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\nGROUP_REGEX = re.compile(r\"^\\s*(\\#|\\%35)(?P<group>[a-z0-9_-]+)\", re.I)\n\nCONTACT_REGEX = re.compile(r\"^\\s*(\\@|\\%40)?(?P<contact>[a-z0-9_-]+)\", re.I)\n\n\n# Priorities\nclass SMSEaglePriority:\n    NORMAL = 0\n    HIGH = 1\n\n\nSMSEAGLE_PRIORITIES = (\n    SMSEaglePriority.NORMAL,\n    SMSEaglePriority.HIGH,\n)\n\nSMSEAGLE_PRIORITY_MAP = {\n    # short for 'normal'\n    \"normal\": SMSEaglePriority.NORMAL,\n    # short for 'high'\n    \"+\": SMSEaglePriority.HIGH,\n    \"high\": SMSEaglePriority.HIGH,\n}\n\n\nclass SMSEagleCategory:\n    \"\"\"We define the different category types that we can notify via SMS\n    Eagle.\"\"\"\n\n    PHONE = \"phone\"\n    GROUP = \"group\"\n    CONTACT = \"contact\"\n\n\nSMSEAGLE_CATEGORIES = (\n    SMSEagleCategory.PHONE,\n    SMSEagleCategory.GROUP,\n    SMSEagleCategory.CONTACT,\n)\n\n\nclass NotifySMSEagle(NotifyBase):\n    \"\"\"A wrapper for SMSEagle Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SMS Eagle\"\n\n    # The services URL\n    service_url = \"https://smseagle.eu\"\n\n    # The default protocol\n    protocol = \"smseagle\"\n\n    # The default protocol\n    secure_protocol = \"smseagles\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/smseagle/\"\n\n    # The path we send our notification to\n    notify_path = \"/jsonrpc/sms\"\n\n    # Support attachments\n    attachment_support = True\n\n    # The maxumum length of the text message\n    # The actual limit is 160 but SMSEagle looks after the handling\n    # of large messages in it's upstream service\n    body_maxlen = 1200\n\n    # The maximum targets to include when doing batch transfers\n    default_batch_size = 10\n\n    # We don't support titles for SMSEagle notifications\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{token}@{host}/{targets}\",\n        \"{schema}://{token}@{host}:{port}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"target_group\": {\n                \"name\": _(\"Target Group ID\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"target_contact\": {\n                \"name\": _(\"Target Contact\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"priority\": {\n                \"name\": _(\"Priority\"),\n                \"type\": \"choice:int\",\n                \"values\": SMSEAGLE_PRIORITIES,\n                \"default\": SMSEaglePriority.NORMAL,\n            },\n            \"status\": {\n                \"name\": _(\"Show Status\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"test\": {\n                \"name\": _(\"Test Only\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"flash\": {\n                \"name\": _(\"Flash\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        token=None,\n        targets=None,\n        priority=None,\n        batch=False,\n        status=False,\n        flash=False,\n        test=False,\n        **kwargs,\n    ):\n        \"\"\"Initialize SMSEagle Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Prepare Flash Mode Flag\n        self.flash = flash\n\n        # Prepare Test Mode Flag\n        self.test = test\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        # Set Status type\n        self.status = status\n\n        # Parse our targets\n        self.target_phones = []\n        self.target_groups = []\n        self.target_contacts = []\n\n        # Used for URL generation afterwards only\n        self.invalid_targets = []\n\n        # We always use a token if provided\n        self.token = validate_regex(token if token else self.user)\n        if not self.token:\n            msg = (\n                \"An invalid SMSEagle Access Token\"\n                f\" ({token if token else self.user}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        #\n        # Priority\n        #\n        try:\n            # Acquire our priority if we can:\n            #  - We accept both the integer form as well as a string\n            #    representation\n            self.priority = int(priority)\n\n        except TypeError:\n            # NoneType means use Default; this is an okay exception\n            self.priority = self.template_args[\"priority\"][\"default\"]\n\n        except ValueError:\n            # Input is a string; attempt to get the lookup from our\n            # priority mapping\n            priority = priority.lower().strip()\n\n            # This little bit of black magic allows us to match against\n            # low, lo, l (for low);\n            # normal, norma, norm, nor, no, n (for normal)\n            # ... etc\n            result = (\n                next(\n                    (\n                        key\n                        for key in SMSEAGLE_PRIORITY_MAP\n                        if key.startswith(priority)\n                    ),\n                    None,\n                )\n                if priority\n                else None\n            )\n\n            # Now test to see if we got a match\n            if not result:\n                msg = (\n                    f\"An invalid SMSEagle priority ({priority}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n\n            # store our successfully looked up priority\n            self.priority = SMSEAGLE_PRIORITY_MAP[result]\n\n        if (\n            self.priority is not None\n            and self.priority not in SMSEAGLE_PRIORITY_MAP.values()\n        ):\n            msg = f\"An invalid SMSEagle priority ({priority}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Validate our targerts\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            # Allow 9 digit numbers (without country code)\n            result = is_phone_no(target, min_len=9)\n            if result:\n                # store valid phone number\n                self.target_phones.append(\n                    \"{}{}\".format(\n                        \"\" if target[0] != \"+\" else \"+\", result[\"full\"]\n                    )\n                )\n                continue\n\n            result = GROUP_REGEX.match(target)\n            if result:\n                # Just store group information\n                self.target_groups.append(result.group(\"group\"))\n                continue\n\n            result = CONTACT_REGEX.match(target)\n            if result:\n                # Just store contact information\n                self.target_contacts.append(result.group(\"contact\"))\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid phone/group/contact ({target}) specified.\",\n            )\n            self.invalid_targets.append(target)\n            continue\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform SMSEagle Notification.\"\"\"\n\n        if (\n            not self.target_groups\n            and not self.target_phones\n            and not self.target_contacts\n        ):\n            # There were no services to notify\n            self.logger.warning(\"There were no SMSEagle targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        attachments = []\n        if attach and self.attachment_support:\n            for attachment in attach:\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SMSEagle attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                if not re.match(r\"^image/.*\", attachment.mimetype, re.I):\n                    # Only support images at this time\n                    self.logger.warning(\n                        \"Ignoring unsupported SMSEagle attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    continue\n\n                try:\n                    # Prepare our Attachment in Base64\n                    attachments.append({\n                        \"content_type\": attachment.mimetype,\n                        \"content\": attachment.base64(),\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SMSEagle attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending SMSEagle attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our payload\n        params_template = {\n            # Our Access Token\n            \"access_token\": self.token,\n            # The message to send (populated below)\n            \"message\": None,\n            # 0 = normal priority, 1 = high priority\n            \"highpriority\": self.priority,\n            # Support unicode characters\n            \"unicode\": 1,\n            # sms or mms (if attachment)\n            \"message_type\": \"sms\",\n            # Response Types:\n            #  simple: format response as simple object with one result field\n            #  extended: format response as extended JSON object\n            \"responsetype\": \"extended\",\n            # SMS will be sent as flash message (1 = yes, 0 = no)\n            \"flash\": 1 if self.flash else 0,\n            # Message Simulation\n            \"test\": 1 if self.test else 0,\n        }\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        # Construct our URL\n        notify_url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            notify_url += f\":{self.port}\"\n        notify_url += self.notify_path\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        notify_by = {\n            SMSEagleCategory.PHONE: {\n                \"method\": \"sms.send_sms\",\n                \"target\": \"to\",\n            },\n            SMSEagleCategory.GROUP: {\n                \"method\": \"sms.send_togroup\",\n                \"target\": \"groupname\",\n            },\n            SMSEagleCategory.CONTACT: {\n                \"method\": \"sms.send_tocontact\",\n                \"target\": \"contactname\",\n            },\n        }\n\n        # categories separated into a tuple since notify_by.keys()\n        # returns an unpredicable list in Python 2.7 which causes\n        # tests to fail every so often\n        for category in SMSEAGLE_CATEGORIES:\n            # Create a copy of our template\n            payload = {\n                \"method\": notify_by[category][\"method\"],\n                \"params\": {\n                    notify_by[category][\"target\"]: None,\n                },\n            }\n\n            # Apply Template\n            payload[\"params\"].update(params_template)\n\n            # Set our Message\n            payload[\"params\"][\"message\"] = \"{}{}\".format(\n                \"\" if not self.status else f\"{self.asset.ascii(notify_type)} \",\n                body,\n            )\n\n            if attachments:\n                # Store our attachments\n                payload[\"params\"][\"message_type\"] = \"mms\"\n                payload[\"params\"][\"attachments\"] = attachments\n\n            targets = getattr(self, f\"target_{category}s\")\n            for index in range(0, len(targets), batch_size):\n                # Prepare our recipients\n                payload[\"params\"][notify_by[category][\"target\"]] = \",\".join(\n                    targets[index : index + batch_size]\n                )\n                # Some Debug Logging\n                if self.logger.isEnabledFor(logging.DEBUG):\n                    # Due to attachments; output can be quite heavy and io\n                    # intensive.\n                    # To accommodate this, we only show our debug payload\n                    # information if required.\n                    self.logger.debug(\n                        \"SMSEagle POST URL:\"\n                        f\" {notify_url} \"\n                        f\"(cert_verify={self.verify_certificate!r})\"\n                    )\n                    self.logger.debug(\n                        \"SMSEagle Payload: %s\", sanitize_payload(payload))\n\n                # Always call throttle before any remote server i/o is made\n                self.throttle()\n                try:\n                    r = requests.post(\n                        notify_url,\n                        data=dumps(payload),\n                        headers=headers,\n                        verify=self.verify_certificate,\n                        timeout=self.request_timeout,\n                    )\n\n                    try:\n                        content = loads(r.content)\n\n                        # Store our status\n                        status_str = str(content[\"result\"])\n\n                    except (AttributeError, TypeError, ValueError, KeyError):\n                        # ValueError = r.content is Unparsable\n                        # TypeError = r.content is None\n                        # AttributeError = r is None\n                        # KeyError = 'result' is not found in result\n                        content = {}\n\n                    # The result set can be a list such as:\n                    #   b'{\"result\":[{\"message_id\":4753,\"status\":\"ok\"}]}'\n                    #\n                    # It can also just be as a dictionary:\n                    #   b'{\"result\":{\"message_id\":4753,\"status\":\"ok\"}}'\n                    #\n                    # The below code handles both cases only only fails if a\n                    # non-ok value was returned\n\n                    if (\n                        r.status_code\n                        not in (requests.codes.ok, requests.codes.created)\n                        or not isinstance(content.get(\"result\"), (dict, list))\n                        or (\n                            isinstance(content.get(\"result\"), dict)\n                            and content[\"result\"].get(\"status\") != \"ok\"\n                        )\n                        or (\n                            isinstance(content.get(\"result\"), list)\n                            and next(\n                                (\n                                    True\n                                    for entry in content.get(\"result\")\n                                    if isinstance(entry, dict)\n                                    and entry.get(\"status\") != \"ok\"\n                                ),\n                                False,\n                            )  # pragma: no cover\n                        )\n                    ):\n\n                        # We had a problem\n                        status_str = (\n                            content.get(\"result\")\n                            if content.get(\"result\")\n                            else NotifySMSEagle.http_response_code_lookup(\n                                r.status_code\n                            )\n                        )\n\n                        self.logger.warning(\n                            \"Failed to send {} {} SMSEagle {} notification: \"\n                            \"{}{}error={}.\".format(\n                                len(targets[index : index + batch_size]),\n                                (\n                                    f\"to {targets[index]}\"\n                                    if batch_size == 1\n                                    else \"(s)\"\n                                ),\n                                category,\n                                status_str,\n                                \", \" if status_str else \"\",\n                                r.status_code,\n                            )\n                        )\n\n                        self.logger.debug(\n                            \"Response\"\n                            f\" {category.upper()} Details:\\r\\n{r.content}\"\n                        )\n\n                        # Mark our failure\n                        has_error = True\n                        continue\n\n                    else:\n                        self.logger.info(\n                            \"Sent {} SMSEagle {} notification{}.\".format(\n                                len(targets[index : index + batch_size]),\n                                category,\n                                (\n                                    f\" to {targets[index]}\"\n                                    if batch_size == 1\n                                    else \"(s)\"\n                                ),\n                            )\n                        )\n\n                except requests.RequestException as e:\n                    self.logger.warning(\n                        \"A Connection error occured sending\"\n                        f\" {len(targets[index:index + batch_size])} SMSEagle\"\n                        f\" {category} notification(s).\"\n                    )\n                    self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.token,\n            self.host,\n            self.port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"status\": \"yes\" if self.status else \"no\",\n            \"flash\": \"yes\" if self.flash else \"no\",\n            \"test\": \"yes\" if self.test else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        default_priority = self.template_args[\"priority\"][\"default\"]\n        if self.priority is not None:\n            # Store our priority; but only if it was specified\n            params[\"priority\"] = next(\n                (\n                    key\n                    for key, value in SMSEAGLE_PRIORITY_MAP.items()\n                    if value == self.priority\n                ),\n                default_priority,\n            )  # pragma: no cover\n\n        # Default port handling\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{token}@{hostname}{port}/{targets}?{params}\".format(\n            schema=self.secure_protocol if self.secure else self.protocol,\n            token=self.pprint(\n                self.token, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            targets=\"/\".join([\n                NotifySMSEagle.quote(x, safe=\"#@\")\n                for x in chain(\n                    # Pass phones directly as is\n                    self.target_phones,\n                    # Contacts\n                    [f\"@{x}\" for x in self.target_contacts],\n                    # Groups\n                    [f\"#{x}\" for x in self.target_groups],\n                    # Pass along the same invalid entries as were provided\n                    self.invalid_targets,\n                )\n            ]),\n            params=NotifySMSEagle.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        if batch_size > 1:\n            # Batches can only be sent by group (you can't combine groups into\n            # a single batch)\n            total_targets = 0\n            for c in SMSEAGLE_CATEGORIES:\n                targets = len(getattr(self, f\"target_{c}s\"))\n                total_targets += int(targets / batch_size) + (\n                    1 if targets % batch_size else 0\n                )\n            return total_targets\n\n        # Normal batch count; just count the targets\n        return (\n            len(self.target_phones)\n            + len(self.target_contacts)\n            + len(self.target_groups)\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifySMSEagle.split_path(results[\"fullpath\"])\n\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifySMSEagle.unquote(results[\"qsd\"][\"token\"])\n\n        elif not results[\"password\"] and results[\"user\"]:\n            results[\"token\"] = NotifySMSEagle.unquote(results[\"user\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySMSEagle.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(results[\"qsd\"].get(\"batch\", False))\n\n        # Get Flash Mode Flag\n        results[\"flash\"] = parse_bool(results[\"qsd\"].get(\"flash\", False))\n\n        # Get Test Mode Flag\n        results[\"test\"] = parse_bool(results[\"qsd\"].get(\"test\", False))\n\n        # Get status switch\n        results[\"status\"] = parse_bool(results[\"qsd\"].get(\"status\", False))\n\n        # Get priority\n        if \"priority\" in results[\"qsd\"] and len(results[\"qsd\"][\"priority\"]):\n            results[\"priority\"] = NotifySMSEagle.unquote(\n                results[\"qsd\"][\"priority\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/smsmanager.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API Reference: https://smsmanager.cz/api/http#send\n\n# To use this service you will need a SMS Manager account\n# You will need credits (new accounts start with a few)\n#     https://smsmanager.cz\n#    1. Sign up and get test credit\n#    2. Generate an API key in web administration.\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import (\n    is_phone_no,\n    parse_bool,\n    parse_phone_no,\n    validate_regex,\n)\nfrom .base import NotifyBase\n\n\nclass SMSManagerGateway:\n    \"\"\"The different gateway values.\"\"\"\n\n    HIGH = \"high\"\n    ECONOMY = \"economy\"\n    LOW = \"low\"\n    DIRECT = \"direct\"\n\n\n# Used for verification purposes\nSMS_MANAGER_GATEWAYS = (\n    SMSManagerGateway.HIGH,\n    SMSManagerGateway.ECONOMY,\n    SMSManagerGateway.LOW,\n    SMSManagerGateway.DIRECT,\n)\n\n\nclass NotifySMSManager(NotifyBase):\n    \"\"\"A wrapper for SMS Manager Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SMS Manager\"\n\n    # The services URL\n    service_url = \"https://smsmanager.cz\"\n\n    # All notification requests are secure\n    secure_protocol = (\n        \"smsmgr\",\n        \"smsmanager\",\n    )\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/sms_manager/\"\n\n    # SMS Manager uses the http protocol with JSON requests\n    notify_url = \"https://http-api.smsmanager.cz/Send\"\n\n    # The maximum amount of texts that can go out in one batch\n    default_batch_size = 4000\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}@{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"key\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"from\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"sender\",\n            },\n            \"sender\": {\n                \"alias_of\": \"from\",\n            },\n            \"gateway\": {\n                \"name\": _(\"Gateway\"),\n                \"type\": \"choice:string\",\n                \"values\": SMS_MANAGER_GATEWAYS,\n                \"default\": SMS_MANAGER_GATEWAYS[0],\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        apikey=None,\n        sender=None,\n        targets=None,\n        batch=None,\n        gateway=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize SMS Manager Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Setup our gateway\n        self.gateway = (\n            self.template_args[\"gateway\"][\"default\"]\n            if not isinstance(gateway, str)\n            else gateway.lower()\n        )\n        if self.gateway not in SMS_MANAGER_GATEWAYS:\n            msg = f\"The Gateway specified ({gateway}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Define whether or not we should operate in a batch mode\n        self.batch = (\n            self.template_args[\"batch\"][\"default\"]\n            if batch is None\n            else bool(batch)\n        )\n\n        # Maximum 11 characters and must be approved by administrators of site\n        self.sender = sender[0:11] if isinstance(sender, str) else None\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Parse each phone number we found\n            # It is documented that numbers with a length of 9 characters are\n            # supplemented by \"420\".\n            result = is_phone_no(target, min_len=9)\n            if result:\n                # Carry forward '+' if defined, otherwise do not...\n                self.targets.append(\n                    (\"+\" + result[\"full\"])\n                    if target.lstrip()[0] == \"+\"\n                    else result[\"full\"]\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid phone # ({target}) specified.\",\n            )\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform SMS Manager Notification.\"\"\"\n\n        if not self.targets:\n            # We have nothing to notify\n            self.logger.warning(\"There are no SMS Manager targets to notify\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Prepare our targets\n        targets = (\n            list(self.targets)\n            if batch_size == 1\n            else [\n                self.targets[index : index + batch_size]\n                for index in range(0, len(self.targets), batch_size)\n            ]\n        )\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our payload\n            # Note: Payload is assembled inside of our while-loop due to\n            #       mock testing issues (payload singleton isn't persistent\n            #       when performing follow up checks on the params object.\n            payload = {\n                \"apikey\": self.apikey,\n                \"gateway\": self.gateway,\n                # The number gets populated in the loop below\n                \"number\": None,\n                \"message\": body,\n            }\n\n            if self.sender:\n                # Sender is ony set if specified\n                payload[\"sender\"] = self.sender\n\n            # Printable target details\n            if isinstance(target, list):\n                p_target = f\"{len(target)} targets\"\n\n                # Prepare our target numbers\n                payload[\"number\"] = \";\".join(target)\n\n            else:\n                p_target = target\n                # Prepare our target numbers\n                payload[\"number\"] = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"SMS Manager POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"SMS Manager Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.get(\n                    self.notify_url,\n                    params=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    # set up our status code to use\n                    status_code = r.status_code\n\n                    self.logger.warning(\n                        \"Failed to send SMS Manager notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            p_target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent SMS Manager notification to {p_target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending SMS Manager: to %s \",\n                    p_target,\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol[0], self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"gateway\": self.gateway,\n        }\n\n        if self.sender:\n            # Set our sender if it was set\n            params[\"sender\"] = self.sender\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{apikey}@{targets}?{params}\".format(\n            schema=self.secure_protocol[0],\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=\"/\".join([\n                NotifySMSManager.quote(f\"{x}\", safe=\"+\") for x in self.targets\n            ]),\n            params=NotifySMSManager.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n\n        #\n        # Factor batch into calculation\n        #\n        # Note: Groups always require a separate request (and can not be\n        # included in batch calculations)\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our API Key\n        results[\"apikey\"] = NotifySMSManager.unquote(results[\"user\"])\n\n        # Store our targets\n        results[\"targets\"] = [\n            *NotifySMSManager.parse_phone_no(results[\"host\"]),\n            *NotifySMSManager.split_path(results[\"fullpath\"]),\n        ]\n\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"sender\"] = NotifySMSManager.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n        elif \"sender\" in results[\"qsd\"] and len(results[\"qsd\"][\"sender\"]):\n            # Support sender= value as well to align with SMS Manager API\n            results[\"sender\"] = NotifySMSManager.unquote(\n                results[\"qsd\"][\"sender\"]\n            )\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySMSManager.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            results[\"apikey\"] = NotifySMSManager.unquote(results[\"qsd\"][\"key\"])\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifySMSManager.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        # Define our gateway\n        if \"gateway\" in results[\"qsd\"] and len(results[\"qsd\"][\"gateway\"]):\n            results[\"gateway\"] = NotifySMSManager.unquote(\n                results[\"qsd\"][\"gateway\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/smtp2go.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Signup @ https://smtp2go.com (free accounts available)\n#\n# From your dashboard, you can generate an API Key if you haven't already\n# at https://app.smtp2go.com/settings/apikeys/\n\n# The API Key from here which will look something like:\n#    api-60F0DD0AB5BA11ABA421F23C91C88EF4\n#\n# Knowing this, you can buid your smtp2go url as follows:\n#  smtp2go://{user}@{domain}/{apikey}\n#  smtp2go://{user}@{domain}/{apikey}/{email}\n#\n# You can email as many addresses as you want as:\n#  smtp2go://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN}\n#\n#  The {user}@{domain} effectively assembles the 'from' email address\n#  the email will be transmitted from.  If no email address is specified\n#  then it will also become the 'to' address as well.\n#\nfrom email.utils import formataddr\nfrom json import dumps\nimport logging\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_bool, parse_emails, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\nSMTP2GO_HTTP_ERROR_MAP = {\n    429: \"To many requests.\",\n}\n\n\nclass NotifySMTP2Go(NotifyBase):\n    \"\"\"A wrapper for SMTP2Go Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SMTP2Go\"\n\n    # The services URL\n    service_url = \"https://www.smtp2go.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"smtp2go\"\n\n    # SMTP2Go advertises they allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/smtp2go/\"\n\n    # Notify URL\n    notify_url = \"https://api.smtp2go.com/v3/email/send\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Default Notify Format\n    notify_format = NotifyFormat.HTML\n\n    # The maximum amount of emails that can reside within a single\n    # batch transfer\n    default_batch_size = 100\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}@{host}:{apikey}/\",\n        \"{schema}://{user}@{host}:{apikey}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"host\": {\n                \"name\": _(\"Domain\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"targets\": {\n                \"name\": _(\"Target Emails\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"name\": {\n                \"name\": _(\"From Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"from_name\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"Email Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(\n        self,\n        apikey,\n        targets,\n        cc=None,\n        bcc=None,\n        from_name=None,\n        headers=None,\n        batch=False,\n        **kwargs,\n    ):\n        \"\"\"Initialize SMTP2Go Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid SMTP2Go API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Validate our username\n        if not self.user:\n            msg = \"No SMTP2Go username was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire Email 'To'\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        # Prepare Batch Mode Flag\n        self.batch = batch\n\n        # Get our From username (if specified)\n        self.from_name = from_name\n\n        # Get our from email address\n        self.from_addr = f\"{self.user}@{self.host}\"\n\n        if not is_email(self.from_addr):\n            # Parse Source domain based on from_addr\n            msg = f\"Invalid ~From~ email format: {self.from_addr}\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if targets:\n            # Validate recipients (to:) and drop bad ones:\n            for recipient in parse_emails(targets):\n                result = is_email(recipient)\n                if result:\n                    self.targets.append((\n                        result[\"name\"] if result[\"name\"] else False,\n                        result[\"full_email\"],\n                    ))\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid To email ({recipient}) specified.\",\n                )\n\n        else:\n            # If our target email list is empty we want to add ourselves to it\n            self.targets.append(\n                (self.from_name if self.from_name else False, self.from_addr)\n            )\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n            email = is_email(recipient)\n            if email:\n                self.cc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n            email = is_email(recipient)\n            if email:\n                self.bcc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform SMTP2Go Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\"There are no Email recipients to notify\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Track our potential attachments\n        attachments = []\n\n        if attach and self.attachment_support:\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SMTP2Go attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    # Format our attachment\n                    attachments.append({\n                        \"filename\": (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                        \"fileblob\": attachment.base64(),\n                        \"mimetype\": attachment.mimetype,\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SMTP2Go attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending SMTP2Go attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        sender = formataddr(\n            (self.from_name if self.from_name else False, self.from_addr),\n            charset=\"utf-8\",\n        )\n\n        # Prepare our payload\n        payload = {\n            # API Key\n            \"api_key\": self.apikey,\n            # Base payload options\n            \"sender\": sender,\n            \"subject\": title,\n            # our To array\n            \"to\": [],\n        }\n\n        if attachments:\n            payload[\"attachments\"] = attachments\n\n        if self.notify_format == NotifyFormat.HTML:\n            payload[\"html_body\"] = body\n\n        else:\n            payload[\"text_body\"] = body\n\n        # Create a copy of the targets list\n        emails = list(self.targets)\n\n        for index in range(0, len(emails), batch_size):\n            # Initialize our cc list\n            cc = self.cc - self.bcc\n\n            # Initialize our bcc list\n            bcc = set(self.bcc)\n\n            # Initialize our to list\n            to = []\n\n            for to_addr in self.targets[index : index + batch_size]:\n                # Strip target out of cc list if in To\n                cc = cc - {to_addr[1]}\n\n                # Strip target out of bcc list if in To\n                bcc = bcc - {to_addr[1]}\n\n                # Prepare our `to`\n                to.append(formataddr(to_addr, charset=\"utf-8\"))\n\n            # Prepare our To\n            payload[\"to\"] = to\n\n            if cc:\n                # Format our cc addresses to support the Name field\n                payload[\"cc\"] = [\n                    formataddr(\n                        (self.names.get(addr, False), addr), charset=\"utf-8\"\n                    )\n                    for addr in cc\n                ]\n\n            # Format our bcc addresses to support the Name field\n            if bcc:\n                # set our bcc variable (convert to list first so it's\n                # JSON serializable)\n                payload[\"bcc\"] = list(bcc)\n\n            # Store our header entries if defined into the payload\n            # in their payload\n            if self.headers:\n                payload[\"custom_headers\"] = [\n                    {\"header\": k, \"value\": v} for k, v in self.headers.items()\n                ]\n\n            # Some Debug Logging\n            if self.logger.isEnabledFor(logging.DEBUG):\n                # Due to attachments; output can be quite heavy and io\n                # intensive.\n                # To accommodate this, we only show our debug payload\n                # information if required.\n                self.logger.debug(\n                    \"SMTP2Go POST URL:\"\n                    f\" {self.notify_url} \"\n                    f\"(cert_verify={self.verify_certificate})\"\n                )\n                self.logger.debug(\n                    \"SMTP2Go Payload: %s\", sanitize_payload(payload))\n\n            # For logging output of success and errors; we get a head count\n            # of our outbound details:\n            verbose_dest = (\n                \", \".join(\n                    [x[1] for x in self.targets[index : index + batch_size]]\n                )\n                if len(self.targets[index : index + batch_size]) <= 3\n                else (\n                    f\"{len(self.targets[index:index + batch_size])} recipients\"\n                )\n            )\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code, SMTP2GO_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send SMTP2Go notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            verbose_dest,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent SMTP2Go notification to {verbose_dest}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending\"\n                    f\" SMTP2Go:{verbose_dest} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n            except OSError as e:\n                self.logger.warning(\n                    \"An I/O error occurred while reading attachments\"\n                )\n                self.logger.debug(f\"I/O Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user, self.host, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.from_name is not None:\n            # from_name specified; pass it back on the url\n            params[\"name\"] = self.from_name\n\n        if self.cc:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                \"{}{}\".format(\n                    \"\" if not e not in self.names else f\"{self.names[e]}:\",\n                    e,\n                )\n                for e in self.cc\n            ])\n\n        if self.bcc:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join(self.bcc)\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0][1] == self.from_addr\n        )\n\n        return \"{schema}://{user}@{host}/{apikey}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            host=self.host,\n            user=NotifySMTP2Go.quote(self.user, safe=\"\"),\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=(\n                \"\"\n                if not has_targets\n                else \"/\".join([\n                    NotifySMTP2Go.quote(\n                        \"{}{}\".format(\"\" if not e[0] else f\"{e[0]}:\", e[1]),\n                        safe=\"\",\n                    )\n                    for e in self.targets\n                ])\n            ),\n            params=NotifySMTP2Go.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifySMTP2Go.split_path(results[\"fullpath\"])\n\n        # Our very first entry is reserved for our api key\n        try:\n            results[\"apikey\"] = results[\"targets\"].pop(0)\n\n        except IndexError:\n            # We're done - no API Key found\n            results[\"apikey\"] = None\n\n        if \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n            # Extract from name to associate with from address\n            results[\"from_name\"] = NotifySMTP2Go.unquote(\n                results[\"qsd\"][\"name\"]\n            )\n\n        # Handle 'to' email address\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(results[\"qsd\"][\"to\"])\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = results[\"qsd\"][\"cc\"]\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = results[\"qsd\"][\"bcc\"]\n\n        # Add our Meta Headers that the user can provide with their outbound\n        # emails\n        results[\"headers\"] = {\n            NotifyBase.unquote(x): NotifyBase.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifySMTP2Go.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/sns.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom collections import OrderedDict\nfrom datetime import datetime, timezone\nfrom hashlib import sha256\nimport hmac\nfrom itertools import chain\nimport re\nfrom xml.etree import ElementTree\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Topic Detection\n# Summary: 256 Characters max, only alpha/numeric plus underscore (_) and\n#          dash (-) additionally allowed.\n#\n#   Soure: https://docs.aws.amazon.com/AWSSimpleQueueService/latest\\\n#                   /SQSDeveloperGuide/sqs-limits.html#limits-queues\n#\n# Allow a starting hashtag (#) specification to help eliminate possible\n# ambiguity between a topic that is comprised of all digits and a phone number\nIS_TOPIC = re.compile(r\"^#?(?P<name>[A-Za-z0-9_-]+)\\s*$\")\n\n# Because our AWS Access Key Secret contains slashes, we actually use the\n# region as a delimiter. This is a bit hacky; but it's much easier than having\n# users of this product search though this Access Key Secret and escape all\n# of the forward slashes!\nIS_REGION = re.compile(\n    r\"^\\s*(?P<country>[a-z]{2})-(?P<area>[a-z-]+?)-(?P<no>[0-9]+)\\s*$\", re.I\n)\n\n# Extend HTTP Error Messages\nAWS_HTTP_ERROR_MAP = {\n    403: \"Unauthorized - Invalid Access/Secret Key Combination.\",\n}\n\n\nclass NotifySNS(NotifyBase):\n    \"\"\"A wrapper for AWS SNS (Amazon Simple Notification)\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"AWS Simple Notification Service (SNS)\"\n\n    # The services URL\n    service_url = \"https://aws.amazon.com/sns/\"\n\n    # The default secure protocol\n    secure_protocol = \"sns\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/sns/\"\n\n    # AWS is pretty good for handling data load so request limits\n    # can occur in much shorter bursts\n    request_rate_per_sec = 2.5\n\n    # The maximum length of the body\n    # Source: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{access_key_id}/{secret_access_key}/{region}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"access_key_id\": {\n                \"name\": _(\"Access Key ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"secret_access_key\": {\n                \"name\": _(\"Secret Access Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"region\": {\n                \"name\": _(\"Region\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[a-z]{2}-[a-z-]+?-[0-9]+$\", \"i\"),\n                \"map_to\": \"region_name\",\n            },\n            \"target_phone_no\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n            },\n            \"target_topic\": {\n                \"name\": _(\"Target Topic\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n                \"prefix\": \"#\",\n                \"regex\": (r\"^[A-Za-z0-9_-]+$\", \"i\"),\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"access\": {\n                \"alias_of\": \"access_key_id\",\n            },\n            \"secret\": {\n                \"alias_of\": \"secret_access_key\",\n            },\n            \"region\": {\n                \"alias_of\": \"region\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        access_key_id,\n        secret_access_key,\n        region_name,\n        targets=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Notify AWS SNS Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Store our AWS API Access Key\n        self.aws_access_key_id = validate_regex(access_key_id)\n        if not self.aws_access_key_id:\n            msg = \"An invalid AWS Access Key ID was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our AWS API Secret Access key\n        self.aws_secret_access_key = validate_regex(secret_access_key)\n        if not self.aws_secret_access_key:\n            msg = (\n                \"An invalid AWS Secret Access Key \"\n                f\"({secret_access_key}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire our AWS Region Name:\n        # eg. us-east-1, cn-north-1, us-west-2, ...\n        self.aws_region_name = validate_regex(\n            region_name, *self.template_tokens[\"region\"][\"regex\"]\n        )\n        if not self.aws_region_name:\n            msg = f\"An invalid AWS Region ({region_name}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Initialize topic list\n        self.topics = []\n\n        # Initialize numbers list\n        self.phone = []\n\n        # Set our notify_url based on our region\n        self.notify_url = f\"https://sns.{self.aws_region_name}.amazonaws.com/\"\n\n        # AWS Service Details\n        self.aws_service_name = \"sns\"\n        self.aws_canonical_uri = \"/\"\n\n        # AWS Authentication Details\n        self.aws_auth_version = \"AWS4\"\n        self.aws_auth_algorithm = \"AWS4-HMAC-SHA256\"\n        self.aws_auth_request = \"aws4_request\"\n\n        # Validate targets and drop bad ones:\n        for target in parse_list(targets):\n            result = is_phone_no(target)\n            if result:\n                # store valid phone number in E.164 format\n                self.phone.append(\"+{}\".format(result[\"full\"]))\n                continue\n\n            result = IS_TOPIC.match(target)\n            if result:\n                # store valid topic\n                self.topics.append(result.group(\"name\"))\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid phone/topic ({target}) specified.\",\n            )\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Wrapper to send_notification since we can alert more then one\n        channel.\"\"\"\n\n        if len(self.phone) == 0 and len(self.topics) == 0:\n            # We have a bot token and no target(s) to message\n            self.logger.warning(\"No AWS targets to notify.\")\n            return False\n\n        # Initiaize our error tracking\n        error_count = 0\n\n        # Create a copy of our phone #'s to notify against\n        phone = list(self.phone)\n        topics = list(self.topics)\n\n        while len(phone) > 0:\n\n            # Get Phone No\n            no = phone.pop(0)\n\n            # Prepare SNS Message Payload\n            payload = {\n                \"Action\": \"Publish\",\n                \"Message\": body,\n                \"Version\": \"2010-03-31\",\n                \"PhoneNumber\": no,\n            }\n\n            (result, _) = self._post(payload=payload, to=no)\n            if not result:\n                error_count += 1\n\n        # Send all our defined topic id's\n        while len(topics):\n\n            # Get Topic\n            topic = topics.pop(0)\n\n            # First ensure our topic exists, if it doesn't, it gets created\n            payload = {\n                \"Action\": \"CreateTopic\",\n                \"Version\": \"2010-03-31\",\n                \"Name\": topic,\n            }\n\n            (result, response) = self._post(payload=payload, to=topic)\n            if not result:\n                error_count += 1\n                continue\n\n            # Get the Amazon Resource Name\n            topic_arn = response.get(\"topic_arn\")\n            if not topic_arn:\n                # Could not acquire our topic; we're done\n                error_count += 1\n                continue\n\n            # Build our payload now that we know our topic_arn\n            payload = {\n                \"Action\": \"Publish\",\n                \"Version\": \"2010-03-31\",\n                \"TopicArn\": topic_arn,\n                \"Message\": body,\n            }\n\n            # Send our payload to AWS\n            (result, _) = self._post(payload=payload, to=topic)\n            if not result:\n                error_count += 1\n\n        return error_count == 0\n\n    def _post(self, payload, to):\n        \"\"\"Wrapper to request.post() to manage it's response better and make\n        the send() function cleaner and easier to maintain.\n\n        This function returns True if the _post was successful and False if it\n        wasn't.\n        \"\"\"\n\n        # Always call throttle before any remote server i/o is made; for AWS\n        # time plays a huge factor in the headers being sent with the payload.\n        # So for AWS (SNS) requests we must throttle before they're generated\n        # and not directly before the i/o call like other notification\n        # services do.\n        self.throttle()\n\n        # Convert our payload from a dict() into a urlencoded string\n        payload = NotifySNS.urlencode(payload)\n\n        # Prepare our Notification URL\n        # Prepare our AWS Headers based on our payload\n        headers = self.aws_prepare_request(payload)\n\n        self.logger.debug(\n            \"AWS POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"AWS Payload: {payload!s}\")\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifySNS.http_response_code_lookup(\n                    r.status_code, AWS_HTTP_ERROR_MAP\n                )\n\n                self.logger.warning(\n                    \"Failed to send AWS notification to {}: \"\n                    \"{}{}error={}.\".format(\n                        to,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return (False, NotifySNS.aws_response_to_dict(r.text))\n\n            else:\n                self.logger.info(f'Sent AWS notification to \"{to}\".')\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending AWS \"\n                f'notification to \"{to}\".',\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return (False, NotifySNS.aws_response_to_dict(None))\n\n        return (True, NotifySNS.aws_response_to_dict(r.text))\n\n    def aws_prepare_request(self, payload, reference=None):\n        \"\"\"Takes the intended payload and returns the headers for it.\n\n        The payload is presumed to have been already urlencoded()\n        \"\"\"\n\n        # Define our AWS header\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n            # Populated below\n            \"Content-Length\": 0,\n            \"Authorization\": None,\n            \"X-Amz-Date\": None,\n        }\n\n        # Get a reference time (used for header construction)\n        reference = datetime.now(timezone.utc)\n\n        # Provide Content-Length\n        headers[\"Content-Length\"] = str(len(payload))\n\n        # Amazon Date Format\n        amzdate = reference.strftime(\"%Y%m%dT%H%M%SZ\")\n        headers[\"X-Amz-Date\"] = amzdate\n\n        # Credential Scope\n        scope = \"{date}/{region}/{service}/{request}\".format(\n            date=reference.strftime(\"%Y%m%d\"),\n            region=self.aws_region_name,\n            service=self.aws_service_name,\n            request=self.aws_auth_request,\n        )\n\n        # Similar to headers; but a subset.  keys must be lowercase\n        signed_headers = OrderedDict([\n            (\"content-type\", headers[\"Content-Type\"]),\n            (\n                \"host\",\n                f\"{self.aws_service_name}\"\n                f\".{self.aws_region_name}.amazonaws.com\",\n            ),\n            (\"x-amz-date\", headers[\"X-Amz-Date\"]),\n        ])\n\n        #\n        # Build Canonical Request Object\n        #\n        canonical_request = \"\\n\".join([\n            # Method\n            \"POST\",\n            # URL\n            self.aws_canonical_uri,\n            # Query String (none set for POST)\n            \"\",\n            # Header Content (must include \\n at end!)\n            # All entries except characters in amazon date must be\n            # lowercase\n            \"\\n\".join([f\"{k}:{v}\" for k, v in signed_headers.items()]) + \"\\n\",\n            # Header Entries (in same order identified above)\n            \";\".join(signed_headers.keys()),\n            # Payload\n            sha256(payload.encode(\"utf-8\")).hexdigest(),\n        ])\n\n        # Prepare Unsigned Signature\n        to_sign = \"\\n\".join([\n            self.aws_auth_algorithm,\n            amzdate,\n            scope,\n            sha256(canonical_request.encode(\"utf-8\")).hexdigest(),\n        ])\n\n        # Our Authorization header\n        headers[\"Authorization\"] = \", \".join([\n            (\n                f\"{self.aws_auth_algorithm} \"\n                f\"Credential={self.aws_access_key_id}/{scope}\"\n            ),\n            \"SignedHeaders={signed_headers}\".format(\n                signed_headers=\";\".join(signed_headers.keys()),\n            ),\n            f\"Signature={self.aws_auth_signature(to_sign, reference)}\",\n        ])\n\n        return headers\n\n    def aws_auth_signature(self, to_sign, reference):\n        \"\"\"Generates a AWS v4 signature based on provided payload which should\n        be in the form of a string.\"\"\"\n\n        def _sign(key, msg, to_hex=False):\n            \"\"\"Perform AWS Signing.\"\"\"\n            if to_hex:\n                return hmac.new(key, msg.encode(\"utf-8\"), sha256).hexdigest()\n            return hmac.new(key, msg.encode(\"utf-8\"), sha256).digest()\n\n        date = _sign(\n            (self.aws_auth_version + self.aws_secret_access_key).encode(\n                \"utf-8\"\n            ),\n            reference.strftime(\"%Y%m%d\"),\n        )\n\n        region = _sign(date, self.aws_region_name)\n        service = _sign(region, self.aws_service_name)\n        signed = _sign(service, self.aws_auth_request)\n        return _sign(signed, to_sign, to_hex=True)\n\n    @staticmethod\n    def aws_response_to_dict(aws_response):\n        \"\"\"Takes an AWS Response object as input and returns it as a dictionary\n        but not befor extracting out what is useful to us first.\n\n        eg:\n          IN:\n            <CreateTopicResponse\n                  xmlns=\"http://sns.amazonaws.com/doc/2010-03-31/\">\n              <CreateTopicResult>\n                <TopicArn>arn:aws:sns:us-east-1:000000000000:abcd</TopicArn>\n                   </CreateTopicResult>\n               <ResponseMetadata>\n               <RequestId>604bef0f-369c-50c5-a7a4-bbd474c83d6a</RequestId>\n               </ResponseMetadata>\n           </CreateTopicResponse>\n\n          OUT:\n           {\n              type: 'CreateTopicResponse',\n              request_id: '604bef0f-369c-50c5-a7a4-bbd474c83d6a',\n              topic_arn: 'arn:aws:sns:us-east-1:000000000000:abcd',\n           }\n        \"\"\"\n\n        # Define ourselves a set of directives we want to keep if found and\n        # then identify the value we want to map them to in our response\n        # object\n        aws_keep_map = {\n            \"RequestId\": \"request_id\",\n            \"TopicArn\": \"topic_arn\",\n            \"MessageId\": \"message_id\",\n            # Error Message Handling\n            \"Type\": \"error_type\",\n            \"Code\": \"error_code\",\n            \"Message\": \"error_message\",\n        }\n\n        # A default response object that we'll manipulate as we pull more data\n        # from our AWS Response object\n        response = {\n            \"type\": None,\n            \"request_id\": None,\n        }\n\n        try:\n            # we build our tree, but not before first eliminating any\n            # reference to namespacing (if present) as it makes parsing\n            # the tree so much easier.\n            root = ElementTree.fromstring(\n                re.sub(r' xmlns=\"[^\"]+\"', \"\", aws_response, count=1)\n            )\n\n            # Store our response tag object name\n            response[\"type\"] = str(root.tag)\n\n            def _xml_iter(root, response):\n                if len(root) > 0:\n                    for child in root:\n                        # use recursion to parse everything\n                        _xml_iter(child, response)\n\n                elif root.tag in aws_keep_map:\n                    response[aws_keep_map[root.tag]] = (root.text).strip()\n\n            # Recursivly iterate over our AWS Response to extract the\n            # fields we're interested in in efforts to populate our response\n            # object.\n            _xml_iter(root, response)\n\n        except (ElementTree.ParseError, TypeError):\n            # bad data just causes us to generate a bad response\n            pass\n\n        return response\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.aws_access_key_id,\n            self.aws_secret_access_key,\n            self.aws_region_name,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return (\n            \"{schema}://{key_id}/{key_secret}/{region}/{targets}/\"\n            \"?{params}\".format(\n                schema=self.secure_protocol,\n                key_id=self.pprint(self.aws_access_key_id, privacy, safe=\"\"),\n                key_secret=self.pprint(\n                    self.aws_secret_access_key,\n                    privacy,\n                    mode=PrivacyMode.Secret,\n                    safe=\"\",\n                ),\n                region=NotifySNS.quote(self.aws_region_name, safe=\"\"),\n                targets=\"/\".join([\n                    NotifySNS.quote(x)\n                    for x in chain(\n                        # Phone # are already prefixed with a plus symbol\n                        self.phone,\n                        # Topics are prefixed with a pound/hashtag symbol\n                        [f\"#{x}\" for x in self.topics],\n                    )\n                ]),\n                params=NotifySNS.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.phone) + len(self.topics)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The AWS Access Key ID is stored in the hostname\n        access_key_id = NotifySNS.unquote(results[\"host\"])\n\n        # Our AWS Access Key Secret contains slashes in it which unfortunately\n        # means it is of variable length after the hostname.  Since we require\n        # that the user provides the region code, we intentionally use this\n        # as our delimiter to detect where our Secret is.\n        secret_access_key = None\n        region_name = None\n\n        # We need to iterate over each entry in the fullpath and find our\n        # region. Once we get there we stop and build our secret from our\n        # accumulated data.\n        secret_access_key_parts = []\n\n        # Start with a list of entries to work with\n        entries = NotifySNS.split_path(results[\"fullpath\"])\n\n        # Section 1: Get Region and Access Secret\n        index = 0\n        for i, entry in enumerate(entries):\n\n            # Are we at the region yet?\n            result = IS_REGION.match(entry)\n            if result:\n                # We found our Region; Rebuild our access key secret based on\n                # all entries we found prior to this:\n                secret_access_key = \"/\".join(secret_access_key_parts)\n\n                # Ensure region is nicely formatted\n                region_name = \"{country}-{area}-{no}\".format(\n                    country=result.group(\"country\").lower(),\n                    area=result.group(\"area\").lower(),\n                    no=result.group(\"no\"),\n                )\n\n                # Track our index as we'll use this to grab the remaining\n                # content in the next Section\n                index = i + 1\n\n                # We're done with Section 1\n                break\n\n            # Store our secret parts\n            secret_access_key_parts.append(entry)\n\n        # Section 2: Get our Recipients (basically all remaining entries)\n        results[\"targets\"] = entries[index:]\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifySNS.parse_list(results[\"qsd\"][\"to\"])\n\n        # Handle secret_access_key over-ride\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            results[\"secret_access_key\"] = NotifySNS.unquote(\n                results[\"qsd\"][\"secret\"]\n            )\n        else:\n            results[\"secret_access_key\"] = secret_access_key\n\n        # Handle access key id over-ride\n        if \"access\" in results[\"qsd\"] and len(results[\"qsd\"][\"access\"]):\n            results[\"access_key_id\"] = NotifySNS.unquote(\n                results[\"qsd\"][\"access\"]\n            )\n        else:\n            results[\"access_key_id\"] = access_key_id\n\n        # Handle region name id over-ride\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            results[\"region_name\"] = NotifySNS.unquote(\n                results[\"qsd\"][\"region\"]\n            )\n        else:\n            results[\"region_name\"] = region_name\n\n        # Return our result set\n        return results\n"
  },
  {
    "path": "apprise/plugins/sparkpost.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Signup @ https://www.sparkpost.com\n#\n# Ensure you've added a Senders Domain and have generated yourself an\n# API Key at:\n#   https://app.sparkpost.com/dashboard\n\n# Note: For SMTP Access, your API key must have at least been granted the\n#   'Send via SMTP' privileges.\n\n# From here you can click on the domain you're interested in. You can acquire\n# the API Key from here which will look something like:\n#    1e1d479fcf1a87527e9411e083c700689fa1acdc\n#\n# Knowing this, you can buid your sparkpost url as follows:\n#  sparkpost://{user}@{domain}/{apikey}\n#  sparkpost://{user}@{domain}/{apikey}/{email}\n#\n# You can email as many addresses as you want as:\n#  sparkpost://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN}\n#\n#  The {user}@{domain} effectively assembles the 'from' email address\n#  the email will be transmitted from.  If no email address is specified\n#  then it will also become the 'to' address as well.\n#\n#  The {domain} must cross reference a domain you've set up with Spark Post\n#\n# API Documentation: https://developers.sparkpost.com/api/\n# Specifically: https://developers.sparkpost.com/api/transmissions/\nimport contextlib\nfrom email.utils import formataddr\nfrom json import dumps, loads\nimport logging\n\nimport requests\n\nfrom .. import exception\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_bool, parse_emails, validate_regex\nfrom ..utils.sanitize import sanitize_payload\nfrom .base import NotifyBase\n\n# Provide some known codes SparkPost uses and what they translate to:\n# Based on https://www.sparkpost.com/docs/tech-resources/extended-error-codes/\nSPARKPOST_HTTP_ERROR_MAP = {\n    400: \"A bad request was made to the server\",\n    401: \"Invalid User ID and/or Unauthorized User\",\n    403: \"Permission Denied; the provided API Key was not valid\",\n    404: \"There is a problem with the server query URI.\",\n    405: \"Invalid HTTP method\",\n    420: \"Sending limit reached.\",\n    422: \"Invalid data/format/type/length\",\n    429: \"To many requests per sec; rate limit\",\n}\n\n\nclass SparkPostRegion:\n    \"\"\"Regions.\"\"\"\n\n    US = \"us\"\n    EU = \"eu\"\n\n\n# SparkPost APIs\nSPARKPOST_API_LOOKUP = {\n    SparkPostRegion.US: \"https://api.sparkpost.com/api/v1\",\n    SparkPostRegion.EU: \"https://api.eu.sparkpost.com/api/v1\",\n}\n\n# A List of our regions we can use for verification\nSPARKPOST_REGIONS = (\n    SparkPostRegion.US,\n    SparkPostRegion.EU,\n)\n\n\nclass NotifySparkPost(NotifyBase):\n    \"\"\"A wrapper for SparkPost Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"SparkPost\"\n\n    # The services URL\n    service_url = \"https://sparkpost.com/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # All notification requests are secure\n    secure_protocol = \"sparkpost\"\n\n    # SparkPost advertises they allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # Words straight from their website:\n    #    https://developers.sparkpost.com/api/#header-rate-limiting\n    # These limits are dynamic, but as a general rule, wait 1 to 5 seconds\n    # after receiving a 429 response before requesting again.\n\n    # As a simple work around, this is what we will do... Wait X seconds\n    # (defined below) before trying again when we get a 429 error\n    sparkpost_retry_wait_sec = 5\n\n    # The maximum number of times we'll retry to send our message when we've\n    # reached a throttling situatin before giving up\n    sparkpost_retry_attempts = 3\n\n    # The maximum amount of emails that can reside within a single\n    # batch transfer based on:\n    #  https://www.sparkpost.com/docs/tech-resources/\\\n    #       smtp-rest-api-performance/#sending-via-the-transmission-rest-api\n    default_batch_size = 2000\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/sparkpost/\"\n\n    # Default Notify Format\n    notify_format = NotifyFormat.HTML\n\n    # Define object templates\n    templates = (\n        \"{schema}://{user}@{host}:{apikey}/\",\n        \"{schema}://{user}@{host}:{apikey}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"user\": {\n                \"name\": _(\"User Name\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"host\": {\n                \"name\": _(\"Domain\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"targets\": {\n                \"name\": _(\"Target Emails\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"name\": {\n                \"name\": _(\"From Name\"),\n                \"type\": \"string\",\n                \"map_to\": \"from_name\",\n            },\n            \"region\": {\n                \"name\": _(\"Region Name\"),\n                \"type\": \"choice:string\",\n                \"values\": SPARKPOST_REGIONS,\n                \"default\": SparkPostRegion.US,\n                \"map_to\": \"region_name\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"cc\": {\n                \"name\": _(\"Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"bcc\": {\n                \"name\": _(\"Blind Carbon Copy\"),\n                \"type\": \"list:string\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"Email Header\"),\n            \"prefix\": \"+\",\n        },\n        \"tokens\": {\n            \"name\": _(\"Template Tokens\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        apikey,\n        targets,\n        cc=None,\n        bcc=None,\n        from_name=None,\n        region_name=None,\n        headers=None,\n        tokens=None,\n        batch=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize SparkPost Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(apikey)\n        if not self.apikey:\n            msg = f\"An invalid SparkPost API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Validate our username\n        if not self.user:\n            msg = \"No SparkPost username was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Acquire Email 'To'\n        self.targets = []\n\n        # Acquire Carbon Copies\n        self.cc = set()\n\n        # Acquire Blind Carbon Copies\n        self.bcc = set()\n\n        # For tracking our email -> name lookups\n        self.names = {}\n\n        # Store our region\n        try:\n            self.region_name = (\n                self.template_args[\"region\"][\"default\"]\n                if region_name is None\n                else region_name.lower()\n            )\n\n            if self.region_name not in SPARKPOST_REGIONS:\n                # allow the outer except to handle this common response\n                raise IndexError()\n\n        except (AttributeError, IndexError, TypeError):\n            # Invalid region specified\n            msg = f\"The SparkPost region specified ({region_name}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        # Get our From username (if specified)\n        self.from_name = from_name\n\n        # Get our from email address\n        self.from_addr = f\"{self.user}@{self.host}\"\n\n        if not is_email(self.from_addr):\n            # Parse Source domain based on from_addr\n            msg = f\"Invalid ~From~ email format: {self.from_addr}\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        self.tokens = {}\n        if tokens:\n            # Store our template tokens\n            self.tokens.update(tokens)\n\n        # Prepare Batch Mode Flag\n        self.batch = (\n            self.template_args[\"batch\"][\"default\"] if batch is None else batch\n        )\n\n        if targets:\n            # Validate recipients (to:) and drop bad ones:\n            for recipient in parse_emails(targets):\n                result = is_email(recipient)\n                if result:\n                    self.targets.append((\n                        result[\"name\"] if result[\"name\"] else False,\n                        result[\"full_email\"],\n                    ))\n                    continue\n\n                self.logger.warning(\n                    f\"Dropped invalid To email ({recipient}) specified.\",\n                )\n\n        else:\n            # If our target email list is empty we want to add ourselves to it\n            self.targets.append(\n                (self.from_name if self.from_name else False, self.from_addr)\n            )\n\n        # Validate recipients (cc:) and drop bad ones:\n        for recipient in parse_emails(cc):\n            email = is_email(recipient)\n            if email:\n                self.cc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid Carbon Copy email ({recipient}) specified.\",\n            )\n\n        # Validate recipients (bcc:) and drop bad ones:\n        for recipient in parse_emails(bcc):\n            email = is_email(recipient)\n            if email:\n                self.bcc.add(email[\"full_email\"])\n\n                # Index our name (if one exists)\n                self.names[email[\"full_email\"]] = (\n                    email[\"name\"] if email[\"name\"] else False\n                )\n                continue\n\n            self.logger.warning(\n                \"Dropped invalid Blind Carbon Copy email \"\n                f\"({recipient}) specified.\",\n            )\n\n    def __post(self, payload, retry):\n        \"\"\"Performs the actual post and returns the response.\"\"\"\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": self.apikey,\n        }\n\n        # Prepare our URL as it's based on our hostname\n        url = f\"{SPARKPOST_API_LOOKUP[self.region_name]}/transmissions/\"\n\n        # Some Debug Logging\n        if self.logger.isEnabledFor(logging.DEBUG):\n            # Due to attachments; output can be quite heavy and io intensive\n            # To accommodate this, we only show our debug payload information\n            # if required.\n            self.logger.debug(\n                \"SparkPost POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(\n                \"SparkPost Payload: %s\", sanitize_payload(payload))\n\n        wait = None\n\n        # For logging output of success and errors; we get a head count\n        # of our outbound details:\n        verbose_dest = (\n            \", \".join([x[\"address\"][\"email\"] for x in payload[\"recipients\"]])\n            if len(payload[\"recipients\"]) <= 3\n            else \"{} recipients\".format(len(payload[\"recipients\"]))\n        )\n\n        # Initialize our response object\n        json_response = {}\n\n        # Set ourselves a status code\n        status_code = -1\n\n        while 1:  # pragma: no branch\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle(wait=wait)\n            try:\n                r = requests.post(\n                    url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # A Good response (200) looks like this:\n                #     \"results\": {\n                #       \"total_rejected_recipients\": 0,\n                #       \"total_accepted_recipients\": 1,\n                #       \"id\": \"11668787484950529\"\n                #        }\n                #     }\n                #\n                # A Bad response looks like this:\n                # {\n                #   \"errors\": [\n                #     {\n                #       \"description\":\n                #            \"Unconfigured or unverified sending domain.\",\n                #       \"code\": \"7001\",\n                #       \"message\": \"Invalid domain\"\n                #     }\n                #   ]\n                # }\n                #\n                with contextlib.suppress(\n                        AttributeError, TypeError, ValueError):\n                    # Load our JSON Object if we can\n                    # ValueError = r.content is Unparsable\n                    # TypeError = r.content is None\n                    # AttributeError = r is None\n                    json_response = loads(r.content)\n\n                status_code = r.status_code\n\n                payload[\"recipients\"] = []\n                if status_code == requests.codes.ok:\n                    self.logger.info(\n                        f\"Sent SparkPost notification to {verbose_dest}.\"\n                    )\n                    return status_code, json_response\n\n                # We had a problem if we get here\n                status_str = NotifyBase.http_response_code_lookup(\n                    status_code, SPARKPOST_API_LOOKUP\n                )\n\n                self.logger.warning(\n                    \"Failed to send SparkPost notification to {}: \"\n                    \"{}{}error={}.\".format(\n                        verbose_dest,\n                        status_str,\n                        \", \" if status_str else \"\",\n                        status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                if status_code == requests.codes.too_many_requests and retry:\n                    retry = retry - 1\n                    if retry > 0:\n                        wait = self.sparkpost_retry_wait_sec\n                        continue\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending SparkPost \"\n                    \"notification\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Anything else and we're done\n            return status_code, json_response\n\n        # Our code will never reach here (outside of infinite while loop above)\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform SparkPost Notification.\"\"\"\n\n        if not self.targets:\n            # There is no one to email; we're done\n            self.logger.warning(\n                \"There are no SparkPost Email recipients to notify\"\n            )\n            return False\n\n        # Initialize our has_error flag\n        has_error = False\n\n        # Send in batches if identified to do so\n        batch_size = 1 if not self.batch else self.default_batch_size\n\n        reply_to = formataddr(\n            (self.from_name if self.from_name else False, self.from_addr),\n            charset=\"utf-8\",\n        )\n\n        payload = {\n            \"options\": {\n                # When set to True, an image is included with the email which\n                # is used to detect if the user looked at the image or not.\n                \"open_tracking\": False,\n                # Track if links were clicked that were found within email\n                \"click_tracking\": False,\n            },\n            \"content\": {\n                \"from\": {\n                    \"name\": (\n                        self.from_name if self.from_name else self.app_desc\n                    ),\n                    \"email\": self.from_addr,\n                },\n                # SparkPost does not allow empty subject lines or lines that\n                # only contain whitespace; Since Apprise allows an empty title\n                # parameter we swap empty title entries with the period\n                \"subject\": title if title.strip() else \".\",\n                \"reply_to\": reply_to,\n            },\n        }\n\n        if self.notify_format == NotifyFormat.HTML:\n            payload[\"content\"][\"html\"] = body\n\n        else:\n            payload[\"content\"][\"text\"] = body\n\n        if attach and self.attachment_support:\n            # Prepare ourselves an attachment object\n            payload[\"content\"][\"attachments\"] = []\n\n            for no, attachment in enumerate(attach, start=1):\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SparkPost attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                try:\n                    # Prepare API Upload Payload\n                    payload[\"content\"][\"attachments\"].append({\n                        \"name\": (\n                            attachment.name\n                            if attachment.name\n                            else f\"file{no:03}.dat\"\n                        ),\n                        \"type\": attachment.mimetype,\n                        \"data\": attachment.base64(),\n                    })\n\n                except exception.AppriseException:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access SparkPost attachment\"\n                        f\" {attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                self.logger.debug(\n                    \"Appending SparkPost attachment\"\n                    f\" {attachment.url(privacy=True)}\"\n                )\n\n        # Take a copy of our token dictionary\n        tokens = self.tokens.copy()\n\n        # Apply some defaults template values\n        tokens[\"app_body\"] = body\n        tokens[\"app_title\"] = title\n        tokens[\"app_type\"] = notify_type.value\n        tokens[\"app_id\"] = self.app_id\n        tokens[\"app_desc\"] = self.app_desc\n        tokens[\"app_color\"] = self.color(notify_type)\n        tokens[\"app_url\"] = self.app_url\n\n        # Store our tokens if they're identified\n        payload[\"substitution_data\"] = self.tokens\n\n        # Create a copy of the targets list\n        emails = list(self.targets)\n\n        for index in range(0, len(emails), batch_size):\n            # Generate our email listing\n            payload[\"recipients\"] = []\n\n            # Initialize our cc list\n            cc = self.cc - self.bcc\n\n            # Initialize our bcc list\n            bcc = set(self.bcc)\n\n            # Initialize our headers\n            headers = self.headers.copy()\n\n            for addr in self.targets[index : index + batch_size]:\n                entry = {\n                    \"address\": {\n                        \"email\": addr[1],\n                    }\n                }\n\n                # Strip target out of cc list if in To\n                cc = cc - {addr[1]}\n\n                # Strip target out of bcc list if in To\n                bcc = bcc - {addr[1]}\n\n                if addr[0]:\n                    entry[\"address\"][\"name\"] = addr[0]\n\n                # Add our recipient to our list\n                payload[\"recipients\"].append(entry)\n\n            if cc:\n                # Handle our cc List\n                for addr in cc:\n                    entry = {\n                        \"address\": {\n                            \"email\": addr,\n                            \"header_to\":\n                            # Take the first email in the To\n                            self.targets[index : index + batch_size][0][1],\n                        },\n                    }\n\n                    if self.names.get(addr):\n                        entry[\"address\"][\"name\"] = self.names[addr]\n\n                    # Add our recipient to our list\n                    payload[\"recipients\"].append(entry)\n\n                headers[\"CC\"] = \",\".join(cc)\n\n            # Handle our bcc\n            for addr in bcc:\n                # Add our recipient to our list\n                payload[\"recipients\"].append({\n                    \"address\": {\n                        \"email\": addr,\n                        \"header_to\":\n                        # Take the first email in the To\n                        self.targets[index : index + batch_size][0][1],\n                    },\n                })\n\n            if headers:\n                payload[\"content\"][\"headers\"] = headers\n\n            # Send our message\n            status_code, _response = self.__post(\n                payload, self.sparkpost_retry_attempts\n            )\n\n            # Failed\n            if status_code != requests.codes.ok:\n                has_error = True\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user, self.apikey, self.host)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"region\": self.region_name,\n            \"batch\": \"yes\" if self.batch else \"no\",\n        }\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Append our template tokens into our parameters\n        params.update({f\":{k}\": v for k, v in self.tokens.items()})\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        if self.from_name is not None:\n            # from_name specified; pass it back on the url\n            params[\"name\"] = self.from_name\n\n        if self.cc:\n            # Handle our Carbon Copy Addresses\n            params[\"cc\"] = \",\".join([\n                \"{}{}\".format(\n                    \"\" if not e not in self.names else f\"{self.names[e]}:\",\n                    e,\n                )\n                for e in self.cc\n            ])\n\n        if self.bcc:\n            # Handle our Blind Carbon Copy Addresses\n            params[\"bcc\"] = \",\".join(self.bcc)\n\n        # a simple boolean check as to whether we display our target emails\n        # or not\n        has_targets = not (\n            len(self.targets) == 1 and self.targets[0][1] == self.from_addr\n        )\n\n        return \"{schema}://{user}@{host}/{apikey}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            host=self.host,\n            user=NotifySparkPost.quote(self.user, safe=\"\"),\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            targets=(\n                \"\"\n                if not has_targets\n                else \"/\".join([\n                    NotifySparkPost.quote(\n                        \"{}{}\".format(\"\" if not e[0] else f\"{e[0]}:\", e[1]),\n                        safe=\"\",\n                    )\n                    for e in self.targets\n                ])\n            ),\n            params=NotifySparkPost.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        #\n        # Factor batch into calculation\n        #\n        batch_size = 1 if not self.batch else self.default_batch_size\n        targets = len(self.targets)\n        if batch_size > 1:\n            targets = int(targets / batch_size) + (\n                1 if targets % batch_size else 0\n            )\n\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifySparkPost.split_path(results[\"fullpath\"])\n\n        # Our very first entry is reserved for our api key\n        try:\n            results[\"apikey\"] = results[\"targets\"].pop(0)\n\n        except IndexError:\n            # We're done - no API Key found\n            results[\"apikey\"] = None\n\n        if \"name\" in results[\"qsd\"] and len(results[\"qsd\"][\"name\"]):\n            # Extract from name to associate with from address\n            results[\"from_name\"] = NotifySparkPost.unquote(\n                results[\"qsd\"][\"name\"]\n            )\n\n        if \"region\" in results[\"qsd\"] and len(results[\"qsd\"][\"region\"]):\n            # Extract region\n            results[\"region_name\"] = NotifySparkPost.unquote(\n                results[\"qsd\"][\"region\"]\n            )\n\n        # Handle 'to' email address\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"].append(results[\"qsd\"][\"to\"])\n\n        # Handle Carbon Copy Addresses\n        if \"cc\" in results[\"qsd\"] and len(results[\"qsd\"][\"cc\"]):\n            results[\"cc\"] = results[\"qsd\"][\"cc\"]\n\n        # Handle Blind Carbon Copy Addresses\n        if \"bcc\" in results[\"qsd\"] and len(results[\"qsd\"][\"bcc\"]):\n            results[\"bcc\"] = results[\"qsd\"][\"bcc\"]\n\n        # Add our Meta Headers that the user can provide with their outbound\n        # emails\n        results[\"headers\"] = {\n            NotifyBase.unquote(x): NotifyBase.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Add our template tokens (if defined)\n        results[\"tokens\"] = {\n            NotifyBase.unquote(x): NotifyBase.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifySparkPost.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/spike.py",
    "content": "#\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Details at:\n# https://www.spike.sh/docs/alerts/send-alerts-to-spike/\n\nimport json\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifySpike(NotifyBase):\n    \"\"\"A wrapper for Spike.sh Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Spike.sh\")\n\n    # The services URL\n    service_url = \"https://www.spike.sh/\"\n\n    # The default secure protocol\n    secure_protocol = \"spike\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/spike/\"\n\n    # URL used to send notifications with\n    notify_url = \"https://api.spike.sh/v1/alerts/\"\n\n    templates = (\"{schema}://{token}\",)\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Integration Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]{32}$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize Spike.sh Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Spike.sh integration key ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.webhook_url = f\"{self.notify_url}{self.token}\"\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n        return (\n            f\"{self.secure_protocol}://\"\n            f\"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/\"\n            f\"?{self.urlencode(params)}\"\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send Spike.sh Notification.\"\"\"\n\n        payload = {\n            \"message\": title if title else body,\n            \"description\": body,\n        }\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            response = requests.post(\n                self.webhook_url,\n                headers=headers,\n                data=json.dumps(payload),\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if response.status_code != requests.codes.ok:\n                self.logger.warning(\n                    \"Spike.sh notification failed: %d - %s\",\n                    response.status_code,\n                    response.text,\n                )\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(f\"Spike.sh Exception: {e}\")\n            return False\n\n        self.logger.info(\"Spike.sh notification sent successfully.\")\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns arguments to re-instantiate the\n        object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            return results\n\n        # Access token\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Extract the account sid from an argument\n            results[\"token\"] = NotifySpike.unquote(results[\"qsd\"][\"token\"])\n        else:\n            # Retrieve the token from the host\n            results[\"token\"] = NotifySpike.unquote(results[\"host\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"Supports reverse-parsing a Spike.sh native URL into an Apprise\n        one.\"\"\"\n        match = re.match(\n            r\"^https://api\\.spike\\.sh/v1/alerts/([a-z0-9]{32})$\", url, re.I\n        )\n        if not match:\n            return None\n\n        return NotifySpike.parse_url(\n            f\"{NotifySpike.secure_protocol}://{match.group(1)}\"\n        )\n"
  },
  {
    "path": "apprise/plugins/splunk.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Splunk On-Call\n# API: https://portal.victorops.com/public/api-docs.html\n# Main: https://www.splunk.com/en_us/products/on-call.html\n# Routing Keys https://help.victorops.com/knowledge-base/routing-keys/\n# Setup: https://help.victorops.com/knowledge-base/rest-endpoint-integration\\\n#       -guide/\n\n\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NOTIFY_TYPES, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass SplunkAction:\n    \"\"\"Tracks the actions supported by Apprise Splunk Plugin.\"\"\"\n\n    # Use mapping (specify :key=arg to over-ride)\n    MAP = \"map\"\n\n    # Creates a timeline event but does not trigger an incident\n    INFO = \"info\"\n\n    # Triggers a warning (possibly causing incident) in all cases\n    WARNING = \"warning\"\n\n    # Triggers an incident in all cases\n    CRITICAL = \"critical\"\n\n    # Acknowldege entity_id provided in all cases\n    ACKNOWLEDGE = \"acknowledgement\"\n\n    # Recovery entity_id provided in all cases\n    RECOVERY = \"recovery\"\n\n    # Resolve (aliase of Recover)\n    RESOLVE = \"resolve\"\n\n\n# Define our Splunk Actions\nSPLUNK_ACTIONS = (\n    SplunkAction.MAP,\n    SplunkAction.INFO,\n    SplunkAction.ACKNOWLEDGE,\n    SplunkAction.WARNING,\n    SplunkAction.RECOVERY,\n    SplunkAction.RESOLVE,\n    SplunkAction.CRITICAL,\n)\n\n\nclass SplunkMessageType:\n    \"\"\"Defines the supported splunk message types.\"\"\"\n\n    # Triggers an incident\n    CRITICAL = \"CRITICAL\"\n\n    # May trigger an incident, depending on your settings\n    WARNING = \"WARNING\"\n\n    # Acks an incident\n    ACKNOWLEDGEMENT = \"ACKNOWLEDGEMENT\"\n\n    # Creates a timeline event but does not trigger an incident\n    INFO = \"INFO\"\n\n    # Resolves an incident\n    RECOVERY = \"RECOVERY\"\n\n\n# Defines our supported message types\nSPLUNK_MESSAGE_TYPES = (\n    SplunkMessageType.CRITICAL,\n    SplunkMessageType.WARNING,\n    SplunkMessageType.ACKNOWLEDGEMENT,\n    SplunkMessageType.INFO,\n    SplunkMessageType.RECOVERY,\n)\n\n\nclass NotifySplunk(NotifyBase):\n    \"\"\"A wrapper for Splunk Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Splunk On-Call\")\n\n    # The services URL\n    service_url = \"https://www.splunk.com/en_us/products/on-call.html\"\n\n    # The default secure protocol\n    secure_protocol = (\"splunk\", \"victorops\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/splunk/\"\n\n    # Notification URL\n    notify_url = (\n        \"https://alert.victorops.com/integrations/generic/20131114/\"\n        \"alert/{apikey}/{routing_key}\"\n    )\n\n    # Define object templates\n    templates = (\n        \"{schema}://{routing_key}@{apikey}\",\n        \"{schema}://{routing_key}@{apikey}/{entity_id}\",\n    )\n\n    # The title is not used\n    title_maxlen = 60\n\n    # body limit\n    body_maxlen = 400\n\n    # Defines our default message mapping\n    splunk_message_map = {\n        # Creates a timeline event but doesnot trigger an incident\n        NotifyType.INFO: SplunkMessageType.INFO,\n        # Resolves an incident\n        NotifyType.SUCCESS: SplunkMessageType.RECOVERY,\n        # May trigger an incident, depending on your settings\n        NotifyType.WARNING: SplunkMessageType.WARNING,\n        # Triggers an incident\n        NotifyType.FAILURE: SplunkMessageType.CRITICAL,\n    }\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n            },\n            \"routing_key\": {\n                \"name\": _(\"Target Routing Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n            },\n            \"entity_id\": {\n                # Provide a value such as: \"disk space/db01.mycompany.com\"\n                \"name\": _(\"Entity ID\"),\n                \"type\": \"string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"apikey\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"routing_key\": {\n                \"alias_of\": \"routing_key\",\n            },\n            \"route\": {\n                \"alias_of\": \"routing_key\",\n            },\n            \"entity_id\": {\n                \"alias_of\": \"entity_id\",\n            },\n            \"action\": {\n                \"name\": _(\"Action\"),\n                \"type\": \"choice:string\",\n                \"values\": SPLUNK_ACTIONS,\n                \"default\": SPLUNK_ACTIONS[0],\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"mapping\": {\n            \"name\": _(\"Action Mapping\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        apikey,\n        routing_key,\n        entity_id=None,\n        action=None,\n        mapping=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Splunk Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"The Splunk API Key specified ({apikey}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.routing_key = validate_regex(\n            routing_key, *self.template_tokens[\"routing_key\"][\"regex\"]\n        )\n        if not self.routing_key:\n            msg = (\n                f\"The Splunk Routing Key specified ({routing_key}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if not (\n            isinstance(entity_id, str) and len(entity_id.strip(\" \\r\\n\\t\\v/\"))\n        ):\n            # Use routing key\n            self.entity_id = f\"{self.app_id}/{self.routing_key}\"\n\n        else:\n            # Assign what was defined:\n            self.entity_id = entity_id.strip(\" \\r\\n\\t\\v/\")\n\n        if action and isinstance(action, str):\n            self.action = next(\n                (a for a in SPLUNK_ACTIONS if a.startswith(action)), None\n            )\n            if self.action not in SPLUNK_ACTIONS:\n                msg = f\"The Splunk action specified ({action}) is invalid.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.action = self.template_args[\"action\"][\"default\"]\n\n        # Store our mappings\n        self.mapping = self.splunk_message_map.copy()\n        if mapping and isinstance(mapping, dict):\n            for k_, v_ in mapping.items():\n                # Get our mapping\n                k = next((t for t in NOTIFY_TYPES if t.startswith(k_)), None)\n                if not k:\n                    msg = (\n                        f\"The Splunk mapping key specified ({k_}) is invalid.\"\n                    )\n                    self.logger.warning(msg)\n                    raise TypeError(msg)\n\n                v_upper = v_.upper()\n                v = next(\n                    (\n                        v\n                        for v in SPLUNK_MESSAGE_TYPES\n                        if v.startswith(v_upper)\n                    ),\n                    None,\n                )\n                if not v:\n                    msg = (\n                        f\"The Splunk mapping value (assigned to {k}) \"\n                        f\"specified ({v_}) is invalid.\"\n                    )\n                    self.logger.warning(msg)\n                    raise TypeError(msg)\n\n                # Update our mapping\n                self.mapping[k] = v\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send our notification.\"\"\"\n\n        # prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Set up our message type\n        if self.action == SplunkAction.MAP:\n            # Use Mapping\n            message_type = self.mapping[notify_type]\n\n        elif self.action == SplunkAction.ACKNOWLEDGE:\n            # Always Acknowledge\n            message_type = SplunkMessageType.ACKNOWLEDGEMENT\n\n        elif self.action == SplunkAction.INFO:\n            # Creates a timeline event but does not trigger an incident\n            message_type = SplunkMessageType.INFO\n\n        elif self.action == SplunkAction.CRITICAL:\n            # Always create Incident\n            message_type = SplunkMessageType.CRITICAL\n\n        elif self.action == SplunkAction.WARNING:\n            # Always trigger warning (potentially creating incident)\n            message_type = SplunkMessageType.WARNING\n\n        else:  # self.action == SplunkAction.RECOVERY or SplunkAction.RESOLVE\n            # Always Recover\n            message_type = SplunkMessageType.RECOVERY\n\n        # Prepare our payload\n        payload = {\n            \"entity_id\": self.entity_id,\n            \"message_type\": message_type,\n            \"entity_display_name\": title if title else self.app_desc,\n            \"state_message\": body,\n            \"monitoring_tool\": self.app_id,\n        }\n\n        notify_url = self.notify_url.format(\n            apikey=self.apikey, routing_key=self.routing_key\n        )\n\n        self.logger.debug(\n            \"Splunk GET URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Splunk Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                notify_url,\n                data=dumps(payload).encode(\"utf-8\"),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            # Sample Response\n            # {\n            #   \"result\" : \"success\",\n            #   \"entity_id\" : \"disk space/db01.mycompany.com\"\n            # }\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifySplunk.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Splunk notification: {}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Splunk notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Splunk notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol[0],\n            self.routing_key,\n            self.entity_id,\n            self.apikey,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"action\": self.action,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Append our assignment extra's into our parameters\n        params.update({f\":{k.value}\": v for k, v in self.mapping.items()})\n\n        return \"{schema}://{routing_key}@{apikey}/{entity_id}?{params}\".format(\n            schema=self.secure_protocol[0],\n            routing_key=self.routing_key,\n            entity_id=(\n                \"\" if self.entity_id == self.routing_key else self.entity_id\n            ),\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            params=NotifySplunk.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        # parse_url already handles getting the `user` and `password` fields\n        # populated.\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Entity ID\n        if \"entity_id\" in results[\"qsd\"] and len(results[\"qsd\"][\"entity_id\"]):\n            results[\"entity_id\"] = NotifySplunk.unquote(\n                results[\"qsd\"][\"entity_id\"]\n            )\n        else:\n            results[\"entity_id\"] = NotifySplunk.unquote(results[\"fullpath\"])\n\n        # API Key\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            results[\"apikey\"] = NotifySplunk.unquote(results[\"qsd\"][\"apikey\"])\n\n        else:\n            results[\"apikey\"] = NotifySplunk.unquote(results[\"host\"])\n\n        # Routing Key\n        if \"routing_key\" in results[\"qsd\"] and len(\n            results[\"qsd\"][\"routing_key\"]\n        ):\n            results[\"routing_key\"] = NotifySplunk.unquote(\n                results[\"qsd\"][\"routing_key\"]\n            )\n\n        elif \"route\" in results[\"qsd\"] and len(results[\"qsd\"][\"route\"]):\n            results[\"routing_key\"] = NotifySplunk.unquote(\n                results[\"qsd\"][\"route\"]\n            )\n\n        else:\n            results[\"routing_key\"] = NotifySplunk.unquote(results[\"user\"])\n\n        # Store our action (if defined)\n        if \"action\" in results[\"qsd\"] and len(results[\"qsd\"][\"action\"]):\n            results[\"action\"] = NotifySplunk.unquote(results[\"qsd\"][\"action\"])\n\n        # store any custom mapping defined\n        results[\"mapping\"] = {\n            NotifySplunk.unquote(x): NotifySplunk.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://alert.victorops.com/integrations/generic/20131114/ \\\n                     alert/apikey/routing_key\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://alert\\.victorops\\.com/integrations/generic/\"\n            r\"(?P<version>[0-9]+)/alert/(?P<apikey>[0-9a-z_-]+)\"\n            r\"(/(?P<routing_key>[^?/]+))\"\n            r\"(/(?P<entity_id>[^?]+))?/*\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifySplunk.parse_url(\n                \"{schema}://{routing_key}@{apikey}/{entity_id}{params}\".format(\n                    schema=NotifySplunk.secure_protocol[0],\n                    apikey=result.group(\"apikey\"),\n                    routing_key=result.group(\"routing_key\"),\n                    entity_id=(\n                        \"\"\n                        if not result.group(\"entity_id\")\n                        else result.group(\"entity_id\")\n                    ),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/spugpush.py",
    "content": "#\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Details at:\n# https://docs.spug.dev/push/\n\nimport json\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifySpugpush(NotifyBase):\n    \"\"\"A wrapper for SpugPush Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"SpugPush\")\n\n    # The services URL\n    service_url = \"https://docs.spug.dev/push/\"\n\n    # The default secure protocol\n    secure_protocol = \"spugpush\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/spugpush/\"\n\n    # URL used to send notifications with\n    notify_url = \"https://push.spug.dev/send/\"\n\n    templates = (\"{schema}://{token}\",)\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-zA-Z0-9_-]{32,64}$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize SpugPush Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The SpugPush token ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.webhook_url = f\"{self.notify_url}{self.token}\"\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n        return (\n            f\"{self.secure_protocol}://\"\n            f\"{self.pprint(self.token, privacy, mode=PrivacyMode.Secret)}/\"\n            f\"?{self.urlencode(params)}\"\n        )\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns a unique identifier for this plugin instance.\"\"\"\n        return (self.secure_protocol, self.token)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Send a SpugPush Notification.\"\"\"\n\n        payload = {\n            \"title\": title if title else body,\n            \"content\": body,\n        }\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        self.throttle()\n        try:\n            response = requests.post(\n                self.webhook_url,\n                headers=headers,\n                data=json.dumps(payload),\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if response.status_code != requests.codes.ok:\n                self.logger.warning(\n                    \"SpugPush notification failed: %d - %s\",\n                    response.status_code,\n                    response.text,\n                )\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(f\"SpugPush Exception: {e}\")\n            return False\n\n        self.logger.info(\"SpugPush notification sent successfully.\")\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns arguments to re-instantiate the\n        object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            return results\n\n        if \"token\" in results[\"qsd\"] and results[\"qsd\"][\"token\"]:\n            results[\"token\"] = NotifySpugpush.unquote(results[\"qsd\"][\"token\"])\n        else:\n            results[\"token\"] = NotifySpugpush.unquote(results[\"host\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"Parse native SpugPush webhook URL into Apprise format.\"\"\"\n        match = re.match(\n            r\"^https://push\\.spug\\.dev/send/([a-z0-9_-]+)$\", url, re.I\n        )\n        if not match:\n            return None\n\n        return NotifySpugpush.parse_url(\n            f\"{NotifySpugpush.secure_protocol}://{match.group(1)}\"\n        )\n"
  },
  {
    "path": "apprise/plugins/streamlabs.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# For this to work correctly you need to register an app\n# and generate an access token\n#\n#\n#  This plugin will simply work using the url of:\n#     streamlabs://access_token/\n#\n# API Documentation on Webhooks:\n#    - https://dev.streamlabs.com/\n#\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\n# calls\nclass StrmlabsCall:\n    ALERT = \"ALERTS\"\n    DONATION = \"DONATIONS\"\n\n\n# A List of calls we can use for verification\nSTRMLABS_CALLS = (\n    StrmlabsCall.ALERT,\n    StrmlabsCall.DONATION,\n)\n\n\n# alerts\nclass StrmlabsAlert:\n    FOLLOW = \"follow\"\n    SUBSCRIPTION = \"subscription\"\n    DONATION = \"donation\"\n    HOST = \"host\"\n\n\n# A List of calls we can use for verification\nSTRMLABS_ALERTS = (\n    StrmlabsAlert.FOLLOW,\n    StrmlabsAlert.SUBSCRIPTION,\n    StrmlabsAlert.DONATION,\n    StrmlabsAlert.HOST,\n)\n\n\nclass NotifyStreamlabs(NotifyBase):\n    \"\"\"A wrapper to Streamlabs Donation Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Streamlabs\"\n\n    # The services URL\n    service_url = \"https://streamlabs.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"strmlabs\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/streamlabs/\"\n\n    # Streamlabs Api endpoint\n    notify_url = \"https://streamlabs.com/api/v1.0/\"\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 255\n\n    # Define object templates\n    templates = (\"{schema}://{access_token}/\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"access_token\": {\n                \"name\": _(\"Access Token\"),\n                \"private\": True,\n                \"required\": True,\n                \"type\": \"string\",\n                \"regex\": (r\"^[a-z0-9]{40}$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"call\": {\n                \"name\": _(\"Call\"),\n                \"type\": \"choice:string\",\n                \"values\": STRMLABS_CALLS,\n                \"default\": StrmlabsCall.ALERT,\n            },\n            \"alert_type\": {\n                \"name\": _(\"Alert Type\"),\n                \"type\": \"choice:string\",\n                \"values\": STRMLABS_ALERTS,\n                \"default\": StrmlabsAlert.DONATION,\n            },\n            \"image_href\": {\n                \"name\": _(\"Image Link\"),\n                \"type\": \"string\",\n                \"default\": \"\",\n            },\n            \"sound_href\": {\n                \"name\": _(\"Sound Link\"),\n                \"type\": \"string\",\n                \"default\": \"\",\n            },\n            \"duration\": {\n                \"name\": _(\"Duration\"),\n                \"type\": \"int\",\n                \"default\": 1000,\n                \"min\": 0,\n            },\n            \"special_text_color\": {\n                \"name\": _(\"Special Text Color\"),\n                \"type\": \"string\",\n                \"default\": \"\",\n                \"regex\": (r\"^[A-Z]$\", \"i\"),\n            },\n            \"amount\": {\n                \"name\": _(\"Amount\"),\n                \"type\": \"int\",\n                \"default\": 0,\n                \"min\": 0,\n            },\n            \"currency\": {\n                \"name\": _(\"Currency\"),\n                \"type\": \"string\",\n                \"default\": \"USD\",\n                \"regex\": (r\"^[A-Z]{3}$\", \"i\"),\n            },\n            \"name\": {\n                \"name\": _(\"Name\"),\n                \"type\": \"string\",\n                \"default\": \"Anon\",\n                \"regex\": (r\"^[^\\s].{1,24}$\", \"i\"),\n            },\n            \"identifier\": {\n                \"name\": _(\"Identifier\"),\n                \"type\": \"string\",\n                \"default\": \"Apprise\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        access_token,\n        call=StrmlabsCall.ALERT,\n        alert_type=StrmlabsAlert.DONATION,\n        image_href=\"\",\n        sound_href=\"\",\n        duration=1000,\n        special_text_color=\"\",\n        amount=0,\n        currency=\"USD\",\n        name=\"Anon\",\n        identifier=\"Apprise\",\n        **kwargs,\n    ):\n        \"\"\"Initialize Streamlabs Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # access token is generated by user\n        # using https://streamlabs.com/api/v1.0/token\n        # Tokens for Streamlabs never need to be refreshed.\n        self.access_token = validate_regex(\n            access_token, *self.template_tokens[\"access_token\"][\"regex\"]\n        )\n        if not self.access_token:\n            msg = \"An invalid Streamslabs access token was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store the call\n        try:\n            if call not in STRMLABS_CALLS:\n                # allow the outer except to handle this common response\n                raise\n            else:\n                self.call = call\n        except Exception as e:\n            # Invalid region specified\n            msg = f\"The streamlabs call specified ({call}) is invalid.\"\n            self.logger.warning(msg)\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            raise TypeError(msg) from None\n\n        # Store the alert_type\n        # only applicable when calling /alerts\n        try:\n            if alert_type not in STRMLABS_ALERTS:\n                # allow the outer except to handle this common response\n                raise\n            else:\n                self.alert_type = alert_type\n        except Exception as e:\n            # Invalid region specified\n            msg = f\"The streamlabs alert type specified ({call}) is invalid.\"\n            self.logger.warning(msg)\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            raise TypeError(msg) from None\n\n        # params only applicable when calling /alerts\n        self.image_href = image_href\n        self.sound_href = sound_href\n        self.duration = duration\n        self.special_text_color = special_text_color\n\n        # only applicable when calling /donations\n        # The amount of this donation.\n        self.amount = amount\n\n        # only applicable when calling /donations\n        # The 3 letter currency code for this donation.\n        # Must be one of the supported currency codes.\n        self.currency = validate_regex(\n            currency, *self.template_args[\"currency\"][\"regex\"]\n        )\n\n        # only applicable when calling /donations\n        if not self.currency:\n            msg = \"An invalid Streamslabs currency was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # only applicable when calling /donations\n        # The name of the donor\n        self.name = validate_regex(name, *self.template_args[\"name\"][\"regex\"])\n        if not self.name:\n            msg = \"An invalid Streamslabs donor was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # An identifier for this donor,\n        # which is used to group donations with the same donor.\n        # only applicable when calling /donations\n        self.identifier = identifier\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Streamlabs notification call (either donation or alert)\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n        if self.call == StrmlabsCall.ALERT:\n\n            data = {\n                \"access_token\": self.access_token,\n                \"type\": self.alert_type.lower(),\n                \"image_href\": self.image_href,\n                \"sound_href\": self.sound_href,\n                \"message\": title,\n                \"user_massage\": body,\n                \"duration\": self.duration,\n                \"special_text_color\": self.special_text_color,\n            }\n\n            try:\n                r = requests.post(\n                    self.notify_url + self.call.lower(),\n                    headers=headers,\n                    data=data,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyStreamlabs.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Streamlabs alert: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n                    return False\n\n                else:\n                    self.logger.info(\"Sent Streamlabs alert.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Streamlabs alert.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n                return False\n\n        if self.call == StrmlabsCall.DONATION:\n            data = {\n                \"name\": self.name,\n                \"identifier\": self.identifier,\n                \"amount\": self.amount,\n                \"currency\": self.currency,\n                \"access_token\": self.access_token,\n                \"message\": body,\n            }\n\n            try:\n                r = requests.post(\n                    self.notify_url + self.call.lower(),\n                    headers=headers,\n                    data=data,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyStreamlabs.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Streamlabs donation: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n                    return False\n\n                else:\n                    self.logger.info(\"Sent Streamlabs donation.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Streamlabs donation.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n                return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.access_token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"call\": self.call,\n            # donation\n            \"name\": self.name,\n            \"identifier\": self.identifier,\n            \"amount\": self.amount,\n            \"currency\": self.currency,\n            # alert\n            \"alert_type\": self.alert_type,\n            \"image_href\": self.image_href,\n            \"sound_href\": self.sound_href,\n            \"duration\": self.duration,\n            \"special_text_color\": self.special_text_color,\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n        return \"{schema}://{access_token}/?{params}\".format(\n            schema=self.secure_protocol,\n            access_token=self.pprint(self.access_token, privacy, safe=\"\"),\n            params=NotifyStreamlabs.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\n\n        Syntax:\n          strmlabs://access_token\n        \"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Store our access code\n        access_token = NotifyStreamlabs.unquote(results[\"host\"])\n        results[\"access_token\"] = access_token\n\n        # call\n        if \"call\" in results[\"qsd\"] and results[\"qsd\"][\"call\"]:\n            results[\"call\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"call\"].strip().upper()\n            )\n        # donation - amount\n        if \"amount\" in results[\"qsd\"] and results[\"qsd\"][\"amount\"]:\n            results[\"amount\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"amount\"]\n            )\n        # donation - currency\n        if \"currency\" in results[\"qsd\"] and results[\"qsd\"][\"currency\"]:\n            results[\"currency\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"currency\"].strip().upper()\n            )\n        # donation - name\n        if \"name\" in results[\"qsd\"] and results[\"qsd\"][\"name\"]:\n            results[\"name\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"name\"].strip().upper()\n            )\n        # donation - identifier\n        if \"identifier\" in results[\"qsd\"] and results[\"qsd\"][\"identifier\"]:\n            results[\"identifier\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"identifier\"].strip().upper()\n            )\n        # alert - alert_type\n        if \"alert_type\" in results[\"qsd\"] and results[\"qsd\"][\"alert_type\"]:\n            results[\"alert_type\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"alert_type\"]\n            )\n        # alert - image_href\n        if \"image_href\" in results[\"qsd\"] and results[\"qsd\"][\"image_href\"]:\n            results[\"image_href\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"image_href\"]\n            )\n        # alert - sound_href\n        if \"sound_href\" in results[\"qsd\"] and results[\"qsd\"][\"sound_href\"]:\n            results[\"sound_href\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"sound_href\"].strip().upper()\n            )\n        # alert - duration\n        if \"duration\" in results[\"qsd\"] and results[\"qsd\"][\"duration\"]:\n            results[\"duration\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"duration\"].strip().upper()\n            )\n        # alert - special_text_color\n        if (\n            \"special_text_color\" in results[\"qsd\"]\n            and results[\"qsd\"][\"special_text_color\"]\n        ):\n            results[\"special_text_color\"] = NotifyStreamlabs.unquote(\n                results[\"qsd\"][\"special_text_color\"].strip().upper()\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/synology.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom .base import NotifyBase\n\n# For API Details see:\n# https://kb.synology.com/en-au/DSM/help/Chat/chat_integration\n\n\nclass NotifySynology(NotifyBase):\n    \"\"\"A wrapper for Synology Chat Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Synology Chat\"\n\n    # The services URL\n    service_url = \"https://www.synology.com/\"\n\n    # The default protocol\n    protocol = \"synology\"\n\n    # The default secure protocol\n    secure_protocol = \"synologys\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/synology_chat/\"\n\n    # Title is to be part of body\n    title_maxlen = 0\n\n    # Disable throttle rate for Synology requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{token}\",\n        \"{schema}://{host}:{port}/{token}\",\n        \"{schema}://{user}@{host}/{token}\",\n        \"{schema}://{user}@{host}:{port}/{token}\",\n        \"{schema}://{user}:{password}@{host}/{token}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{token}\",\n    )\n\n    # Define our tokens; these are the minimum tokens required required to\n    # be passed into this function (as arguments). The syntax appends any\n    # previously defined in the base package and builds onto them\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"Username\"),\n                \"type\": \"string\",\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"file_url\": {\n                \"name\": _(\"Upload\"),\n                \"type\": \"string\",\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n        },\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"headers\": {\n            \"name\": _(\"HTTP Header\"),\n            \"prefix\": \"+\",\n        },\n    }\n\n    def __init__(self, token=None, headers=None, file_url=None, **kwargs):\n        \"\"\"Initialize Synology Chat Object.\n\n        headers can be a dictionary of key/value pairs that you want to\n        additionally include as part of the server headers to post with\n        \"\"\"\n        super().__init__(**kwargs)\n\n        self.token = token\n        if not self.token:\n            msg = f\"An invalid Synology Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.fullpath = kwargs.get(\"fullpath\")\n\n        # A URL to an attachment you want to upload (must be less then 32MB\n        # Acording to API details (at the time of writing plugin)\n        self.file_url = file_url\n\n        self.headers = {}\n        if headers:\n            # Store our extra headers\n            self.headers.update(headers)\n\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n            self.token,\n            self.fullpath.rstrip(\"/\"),\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {}\n\n        if self.file_url:\n            params[\"file_url\"] = self.file_url\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Append our headers into our parameters\n        params.update({f\"+{k}\": v for k, v in self.headers.items()})\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=NotifySynology.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=NotifySynology.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n\n        return (\n            \"{schema}://{auth}{hostname}{port}/{token}\"\n            \"{fullpath}?{params}\".format(\n                schema=self.secure_protocol if self.secure else self.protocol,\n                auth=auth,\n                # never encode hostname since we're expecting it to be a valid\n                # one\n                hostname=self.host,\n                port=(\n                    \"\"\n                    if self.port is None or self.port == default_port\n                    else f\":{self.port}\"\n                ),\n                token=self.pprint(self.token, privacy, safe=\"\"),\n                fullpath=(\n                    NotifySynology.quote(self.fullpath, safe=\"/\")\n                    if self.fullpath\n                    else \"/\"\n                ),\n                params=NotifySynology.urlencode(params),\n            )\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Synology Chat Notification.\"\"\"\n\n        # Prepare HTTP Headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n            \"Accept\": \"*/*\",\n        }\n\n        # Apply any/all header over-rides defined\n        headers.update(self.headers)\n\n        # prepare Synology Object\n        payload = {\n            \"text\": body,\n        }\n\n        if self.file_url:\n            payload[\"file_url\"] = self.file_url\n\n        # Prepare our parameters\n        params = {\n            \"api\": \"SYNO.Chat.External\",\n            \"method\": \"incoming\",\n            \"version\": 2,\n            \"token\": self.token,\n        }\n\n        auth = None\n        if self.user:\n            auth = (self.user, self.password)\n\n        # Set our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        url = f\"{schema}://{self.host}\"\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        # Prepare our Synology API URL\n        url += self.fullpath + \"/webapi/entry.cgi\"\n\n        self.logger.debug(\n            \"Synology Chat POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Synology Chat Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                url,\n                data=f\"payload={dumps(payload)}\",\n                params=params,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code < 200 or r.status_code >= 300:\n                # We had a problem\n                status_str = NotifySynology.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Synology Chat %s notification: \"\n                    \"%serror=%s.\",\n                    status_str,\n                    \", \" if status_str else \"\",\n                    r.status_code,\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent Synology Chat notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Synology \"\n                f\"Chat notification to {self.host}.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Add our headers that the user can potentially over-ride if they wish\n        # to to our returned result set and tidy entries by unquoting them\n        results[\"headers\"] = {\n            NotifySynology.unquote(x): NotifySynology.unquote(y)\n            for x, y in results[\"qsd+\"].items()\n        }\n\n        # Set our token if found as an argument\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifySynology.unquote(results[\"qsd\"][\"token\"])\n\n        else:\n            # Get unquoted entries\n            entries = NotifySynology.split_path(results[\"fullpath\"])\n            if entries:\n                # Pop the first element\n                results[\"token\"] = entries.pop(0)\n\n                # Update our fullpath to not include our token\n                results[\"fullpath\"] = results[\"fullpath\"][\n                    len(results[\"token\"]) + 1:\n                ]\n\n        # Set upload/file_url if not otherwise set\n        if \"file_url\" in results[\"qsd\"] and len(results[\"qsd\"][\"file_url\"]):\n            results[\"file_url\"] = NotifySynology.unquote(\n                results[\"qsd\"][\"file_url\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/syslog.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport syslog\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n\nclass SyslogFacility:\n    \"\"\"All of the supported facilities.\"\"\"\n\n    KERN = \"kern\"\n    USER = \"user\"\n    MAIL = \"mail\"\n    DAEMON = \"daemon\"\n    AUTH = \"auth\"\n    SYSLOG = \"syslog\"\n    LPR = \"lpr\"\n    NEWS = \"news\"\n    UUCP = \"uucp\"\n    CRON = \"cron\"\n    LOCAL0 = \"local0\"\n    LOCAL1 = \"local1\"\n    LOCAL2 = \"local2\"\n    LOCAL3 = \"local3\"\n    LOCAL4 = \"local4\"\n    LOCAL5 = \"local5\"\n    LOCAL6 = \"local6\"\n    LOCAL7 = \"local7\"\n\n\nSYSLOG_FACILITY_MAP = {\n    SyslogFacility.KERN: syslog.LOG_KERN,\n    SyslogFacility.USER: syslog.LOG_USER,\n    SyslogFacility.MAIL: syslog.LOG_MAIL,\n    SyslogFacility.DAEMON: syslog.LOG_DAEMON,\n    SyslogFacility.AUTH: syslog.LOG_AUTH,\n    SyslogFacility.SYSLOG: syslog.LOG_SYSLOG,\n    SyslogFacility.LPR: syslog.LOG_LPR,\n    SyslogFacility.NEWS: syslog.LOG_NEWS,\n    SyslogFacility.UUCP: syslog.LOG_UUCP,\n    SyslogFacility.CRON: syslog.LOG_CRON,\n    SyslogFacility.LOCAL0: syslog.LOG_LOCAL0,\n    SyslogFacility.LOCAL1: syslog.LOG_LOCAL1,\n    SyslogFacility.LOCAL2: syslog.LOG_LOCAL2,\n    SyslogFacility.LOCAL3: syslog.LOG_LOCAL3,\n    SyslogFacility.LOCAL4: syslog.LOG_LOCAL4,\n    SyslogFacility.LOCAL5: syslog.LOG_LOCAL5,\n    SyslogFacility.LOCAL6: syslog.LOG_LOCAL6,\n    SyslogFacility.LOCAL7: syslog.LOG_LOCAL7,\n}\n\nSYSLOG_FACILITY_RMAP = {\n    syslog.LOG_KERN: SyslogFacility.KERN,\n    syslog.LOG_USER: SyslogFacility.USER,\n    syslog.LOG_MAIL: SyslogFacility.MAIL,\n    syslog.LOG_DAEMON: SyslogFacility.DAEMON,\n    syslog.LOG_AUTH: SyslogFacility.AUTH,\n    syslog.LOG_SYSLOG: SyslogFacility.SYSLOG,\n    syslog.LOG_LPR: SyslogFacility.LPR,\n    syslog.LOG_NEWS: SyslogFacility.NEWS,\n    syslog.LOG_UUCP: SyslogFacility.UUCP,\n    syslog.LOG_CRON: SyslogFacility.CRON,\n    syslog.LOG_LOCAL0: SyslogFacility.LOCAL0,\n    syslog.LOG_LOCAL1: SyslogFacility.LOCAL1,\n    syslog.LOG_LOCAL2: SyslogFacility.LOCAL2,\n    syslog.LOG_LOCAL3: SyslogFacility.LOCAL3,\n    syslog.LOG_LOCAL4: SyslogFacility.LOCAL4,\n    syslog.LOG_LOCAL5: SyslogFacility.LOCAL5,\n    syslog.LOG_LOCAL6: SyslogFacility.LOCAL6,\n    syslog.LOG_LOCAL7: SyslogFacility.LOCAL7,\n}\n\n# Used as a lookup when handling the Apprise -> Syslog Mapping\nSYSLOG_PUBLISH_MAP = {\n    NotifyType.INFO: syslog.LOG_INFO,\n    NotifyType.SUCCESS: syslog.LOG_NOTICE,\n    NotifyType.FAILURE: syslog.LOG_CRIT,\n    NotifyType.WARNING: syslog.LOG_WARNING,\n}\n\n\nclass NotifySyslog(NotifyBase):\n    \"\"\"A wrapper for Syslog Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Syslog\"\n\n    # The services URL\n    service_url = \"https://tools.ietf.org/html/rfc5424\"\n\n    # The default protocol\n    protocol = \"syslog\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/syslog/\"\n\n    # No URL Identifier will be defined for this service as there simply isn't\n    # enough details to uniquely identify one dbus:// from another.\n    url_identifier = False\n\n    # Disable throttle rate for Syslog requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://\",\n        \"{schema}://{facility}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"facility\": {\n                \"name\": _(\"Facility\"),\n                \"type\": \"choice:string\",\n                \"values\": list(SYSLOG_FACILITY_MAP),\n                \"default\": SyslogFacility.USER,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"facility\": {\n                # We map back to the same element defined in template_tokens\n                \"alias_of\": \"facility\",\n            },\n            \"logpid\": {\n                \"name\": _(\"Log PID\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"log_pid\",\n            },\n            \"logperror\": {\n                \"name\": _(\"Log to STDERR\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"log_perror\",\n            },\n        },\n    )\n\n    def __init__(\n        self, facility=None, log_pid=True, log_perror=False, **kwargs\n    ):\n        \"\"\"Initialize Syslog Object.\"\"\"\n        super().__init__(**kwargs)\n\n        if facility:\n            try:\n                self.facility = SYSLOG_FACILITY_MAP[facility]\n\n            except KeyError:\n                msg = f\"An invalid syslog facility ({facility}) was specified.\"\n                self.logger.warning(msg)\n                raise TypeError(msg) from None\n        else:\n            self.facility = SYSLOG_FACILITY_MAP[\n                self.template_tokens[\"facility\"][\"default\"]\n            ]\n\n        # Logging Options\n        self.logoptions = 0\n\n        # Include PID with each message.\n        # This may not appear evident if using journalctl since the pid\n        # will always display itself; however it will appear visible\n        # for log_perror combinations\n        self.log_pid = log_pid\n\n        # Print to stderr as well.\n        self.log_perror = log_perror\n\n        if log_pid:\n            self.logoptions |= syslog.LOG_PID\n\n        if log_perror:\n            self.logoptions |= syslog.LOG_PERROR\n\n        # Initialize our logging\n        syslog.openlog(\n            self.app_id, logoption=self.logoptions, facility=self.facility\n        )\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Syslog Notification.\"\"\"\n\n        SYSLOG_PUBLISH_MAP = {\n            NotifyType.INFO: syslog.LOG_INFO,\n            NotifyType.SUCCESS: syslog.LOG_NOTICE,\n            NotifyType.FAILURE: syslog.LOG_CRIT,\n            NotifyType.WARNING: syslog.LOG_WARNING,\n        }\n\n        if title:\n            # Format title\n            body = f\"{title}: {body}\"\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            syslog.syslog(SYSLOG_PUBLISH_MAP[notify_type], body)\n\n        except KeyError:\n            # An invalid notification type was specified\n            self.logger.warning(\n                f\"An invalid notification type ({notify_type}) was specified.\"\n            )\n            return False\n\n        self.logger.info(\"Sent Syslog notification.\")\n\n        return True\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"logperror\": \"yes\" if self.log_perror else \"no\",\n            \"logpid\": \"yes\" if self.log_pid else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{facility}/?{params}\".format(\n            facility=(\n                self.template_tokens[\"facility\"][\"default\"]\n                if self.facility not in SYSLOG_FACILITY_RMAP\n                else SYSLOG_FACILITY_RMAP[self.facility]\n            ),\n            schema=self.protocol,\n            params=NotifySyslog.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        tokens = []\n        if results[\"host\"]:\n            tokens.append(NotifySyslog.unquote(results[\"host\"]))\n\n        # Get our path values\n        tokens.extend(NotifySyslog.split_path(results[\"fullpath\"]))\n\n        # Initialization\n        facility = None\n\n        if tokens:\n            # Store the last entry as the facility\n            facility = tokens[-1].lower()\n\n        # However if specified on the URL, that will over-ride what was\n        # identified\n        if \"facility\" in results[\"qsd\"] and len(results[\"qsd\"][\"facility\"]):\n            facility = results[\"qsd\"][\"facility\"].lower()\n\n        if facility and facility not in SYSLOG_FACILITY_MAP:\n            # Find first match; if no match is found we set the result\n            # to the matching key.  This allows us to throw a TypeError\n            # during the __init__() call. The benifit of doing this\n            # check here is if we do have a valid match, we can support\n            # short form matches like 'u' which will match against user\n            facility = next(\n                (f for f in SYSLOG_FACILITY_MAP if f.startswith(facility)),\n                facility,\n            )\n\n        # Save facility if set\n        if facility:\n            results[\"facility\"] = facility\n\n        # Include PID as part of the message logged\n        results[\"log_pid\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"logpid\", NotifySyslog.template_args[\"logpid\"][\"default\"]\n            )\n        )\n\n        # Print to stderr as well.\n        results[\"log_perror\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"logperror\", NotifySyslog.template_args[\"logperror\"][\"default\"]\n            )\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/techuluspush.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you need to download the app\n# - Apple: https://itunes.apple.com/us/app/\\\n#              push-by-techulus/id1444391917?ls=1&mt=8\n# - Android: https://play.google.com/store/apps/\\\n#              details?id=com.techulus.push\n#\n# You have to sign up through the account via your mobile device.\n#\n# Once you've got your account, you can get your API key from here:\n#   https://push.techulus.com/login.html\n#\n# You can also just get the {apikey} right out of the phone app that is\n# installed.\n#\n# your {apikey} will look something like:\n#   b444a40f-3db9-4224-b489-9a514c41c009\n#\n# You will need to assemble all of your URLs for this plugin to work as:\n#   push://{apikey}\n#\n# Resources\n# - https://push.techulus.com/ - Main Website\n# - https://pushtechulus.docs.apiary.io - API Documentation\n\nfrom json import dumps\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n# Token required as part of the API request\n# Used to prepare our UUID regex matching\nUUID4_RE = (\n    r\"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\"\n)\n\n\nclass NotifyTechulusPush(NotifyBase):\n    \"\"\"A wrapper for Techulus Push Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Techulus Push\"\n\n    # The services URL\n    service_url = \"https://push.techulus.com\"\n\n    # The default secure protocol\n    secure_protocol = \"push\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/techulus/\"\n\n    # Techulus Push uses the http protocol with JSON requests\n    notify_url = \"https://push.techulus.com/api/v1/notify\"\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1000\n\n    # Define object templates\n    templates = (\"{schema}://{apikey}\",)\n\n    # Define our template apikeys\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (rf\"^{UUID4_RE}$\", \"i\"),\n            },\n        },\n    )\n\n    def __init__(self, apikey, **kwargs):\n        \"\"\"Initialize Techulus Push Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # The apikey associated with the account\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Techulus Push API key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Techulus Push Notification.\"\"\"\n\n        # Setup our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"x-api-key\": self.apikey,\n        }\n\n        payload = {\n            \"title\": title,\n            \"body\": body,\n        }\n\n        self.logger.debug(\n            \"Techulus Push POST URL:\"\n            f\" {self.notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Techulus Push Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n                # We had a problem\n                status_str = NotifyTechulusPush.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Techulus Push notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False\n\n            else:\n                self.logger.info(\"Sent Techulus Push notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Techulus Push \"\n                \"notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.apikey)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{apikey}/?{params}\".format(\n            schema=self.secure_protocol,\n            apikey=self.pprint(self.apikey, privacy, safe=\"\"),\n            params=NotifyTechulusPush.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The first apikey is stored in the hostname\n        results[\"apikey\"] = NotifyTechulusPush.unquote(results[\"host\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/telegram.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you need to first access https://api.telegram.org\n# You need to create a bot and acquire it's Token Identifier (bot_token)\n#\n# Basically you need to create a chat with a user called the 'BotFather'\n# and type: /newbot\n#\n# Then follow through the wizard, it will provide you an api key\n# that looks like this:123456789:alphanumeri_characters\n#\n# For each chat_id a bot joins will have a chat_id associated with it.\n# You will need this value as well to send the notification.\n#\n# Log into the webpage version of the site if you like by accessing:\n#    https://web.telegram.org\n#\n# You can't check out to see if your entry is working using:\n#    https://api.telegram.org/botAPI_KEY/getMe\n#\n#    Pay attention to the word 'bot' that must be present infront of your\n#    api key that the BotFather gave you.\n#\n#  For example, a url might look like this:\n#    https://api.telegram.org/bot123456789:alphanumeric_characters/getMe\n#\n# Development API Reference::\n#  - https://core.telegram.org/bots/api\nfrom json import dumps, loads\nimport os\nimport re\n\nimport requests\n\nfrom ..attachment.base import AttachBase\nfrom ..common import (\n    NotifyFormat,\n    NotifyImageSize,\n    NotifyType,\n    PersistentStoreMode,\n)\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\nTELEGRAM_IMAGE_XY = NotifyImageSize.XY_256\n\n# Chat ID is required\n# If the Chat ID is positive, then it's addressed to a single person\n# If the Chat ID is negative, then it's targeting a group\n# We can support :topic (an integer) if specified as well\nIS_CHAT_ID_RE = re.compile(\n    r\"^((?P<idno>-?[0-9]{1,32})|(@|%40)?(?P<name>[a-z_-][a-z0-9_-]+))\"\n    r\"((:|%3A)(?P<topic>[0-9]+))?$\",\n    re.IGNORECASE,\n)\n\n\nclass TelegramMarkdownVersion:\n    \"\"\"Telegram Markdown Version.\"\"\"\n\n    # Classic (Original Telegram Markdown)\n    ONE = \"MARKDOWN\"\n\n    # Supports strikethrough and many other items\n    TWO = \"MarkdownV2\"\n\n\nTELEGRAM_MARKDOWN_VERSION_MAP = {\n    # v1\n    \"v1\": TelegramMarkdownVersion.ONE,\n    \"1\": TelegramMarkdownVersion.ONE,\n    # v2\n    \"v2\": TelegramMarkdownVersion.TWO,\n    \"2\": TelegramMarkdownVersion.TWO,\n    \"default\": TelegramMarkdownVersion.TWO,\n}\n\nTELEGRAM_MARKDOWN_VERSIONS = {\n    # Note: This also acts as a reverse lookup mapping\n    TelegramMarkdownVersion.ONE: \"v1\",\n    TelegramMarkdownVersion.TWO: \"v2\",\n}\n\n\nclass TelegramContentPlacement:\n    \"\"\"The Telegram Content Placement.\"\"\"\n\n    # Before Attachments\n    BEFORE = \"before\"\n    # After Attachments\n    AFTER = \"after\"\n\n\n# Identify Placement Categories\nTELEGRAM_CONTENT_PLACEMENT = (\n    TelegramContentPlacement.BEFORE,\n    TelegramContentPlacement.AFTER,\n)\n\n\nclass NotifyTelegram(NotifyBase):\n    \"\"\"A wrapper for Telegram Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Telegram\"\n\n    # The services URL\n    service_url = \"https://telegram.org/\"\n\n    # The default secure protocol\n    secure_protocol = \"tgram\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/telegram/\"\n\n    # Default Notify Format\n    notify_format = NotifyFormat.HTML\n\n    # Telegram uses the http protocol with JSON requests\n    notify_url = \"https://api.telegram.org/bot\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_256\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 4096\n\n    # The maximum number of characters a telegram attachment caption can be\n    # If an attachment is provided and the body is within the caption limit\n    # then it is captioned with the attachment instead.\n    telegram_caption_maxlen = 1024\n\n    # Title is to be part of body\n    title_maxlen = 0\n\n    # Telegram is limited to sending a maximum of 100 requests per second.\n    request_rate_per_sec = 0.001\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference\n    storage_mode = PersistentStoreMode.AUTO\n\n    # Define object templates\n    templates = (\n        \"{schema}://{bot_token}\",\n        \"{schema}://{bot_token}/{targets}\",\n    )\n\n    # Telegram Attachment Support\n    mime_lookup = (\n        # This list is intentionally ordered so that it can be scanned\n        # from top to bottom.  The last entry is a catch-all\n        # Animations are documented to only support gif or H.264/MPEG-4\n        # Source: https://core.telegram.org/bots/api#sendanimation\n        {\n            \"regex\": re.compile(r\"^(image/gif|video/H264)\", re.I),\n            \"function_name\": \"sendAnimation\",\n            \"key\": \"animation\",\n        },\n        # This entry is intentially placed below the sendAnimiation allowing\n        # it to catch gif files.  This then becomes a catch all to remaining\n        # image types.\n        # Source: https://core.telegram.org/bots/api#sendphoto\n        {\n            \"regex\": re.compile(r\"^image/.*\", re.I),\n            \"function_name\": \"sendPhoto\",\n            \"key\": \"photo\",\n        },\n        # Video is documented to only support .mp4\n        # Source: https://core.telegram.org/bots/api#sendvideo\n        {\n            \"regex\": re.compile(r\"^video/mp4\", re.I),\n            \"function_name\": \"sendVideo\",\n            \"key\": \"video\",\n        },\n        # Voice supports ogg\n        # Source: https://core.telegram.org/bots/api#sendvoice\n        {\n            \"regex\": re.compile(r\"^(application|audio)/ogg\", re.I),\n            \"function_name\": \"sendVoice\",\n            \"key\": \"voice\",\n        },\n        # Audio supports mp3 and m4a only\n        # Source: https://core.telegram.org/bots/api#sendaudio\n        {\n            \"regex\": re.compile(r\"^audio/(mpeg|mp4a-latm)\", re.I),\n            \"function_name\": \"sendAudio\",\n            \"key\": \"audio\",\n        },\n        # Catch All (all other types)\n        # Source: https://core.telegram.org/bots/api#senddocument\n        {\n            \"regex\": re.compile(r\".*\", re.I),\n            \"function_name\": \"sendDocument\",\n            \"key\": \"document\",\n        },\n    )\n\n    # Telegram's HTML support doesn't like having HTML escaped\n    # characters passed into it.  to handle this situation, we need to\n    # search the body for these sequences and convert them to the\n    # output the user expected\n    __telegram_escape_html_entries = (\n        # Comments\n        (re.compile(r\"\\s*<!.+?-->\\s*\", (re.I | re.M | re.S)), \"\", {}),\n        # the following tags are not supported\n        (\n            re.compile(\n                r\"\\s*<\\s*(!?DOCTYPE|p|div|span|body|script|link|\"\n                r\"meta|html|font|head|label|form|input|textarea|select|iframe|\"\n                r\"source|script)([^a-z0-9>][^>]*)?>\\s*\",\n                (re.I | re.M | re.S),\n            ),\n            \"\",\n            {},\n        ),\n        # All closing tags to be removed are put here\n        (\n            re.compile(\n                r\"\\s*<\\s*/(span|body|script|meta|html|font|head|\"\n                r\"label|form|input|textarea|select|ol|ul|link|\"\n                r\"iframe|source|script)([^a-z0-9>][^>]*)?>\\s*\",\n                (re.I | re.M | re.S),\n            ),\n            \"\",\n            {},\n        ),\n        # Bold\n        (\n            re.compile(\n                r\"<\\s*(strong)([^a-z0-9>][^>]*)?>\", (re.I | re.M | re.S)\n            ),\n            \"<b>\",\n            {},\n        ),\n        (\n            re.compile(\n                r\"<\\s*/\\s*(strong)([^a-z0-9>][^>]*)?>\", (re.I | re.M | re.S)\n            ),\n            \"</b>\",\n            {},\n        ),\n        (\n            re.compile(\n                r\"\\s*<\\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\\s*\",\n                (re.I | re.M | re.S),\n            ),\n            \"{}<b>\",\n            {\"html\": \"\\r\\n\"},\n        ),\n        (\n            re.compile(\n                r\"\\s*<\\s*/\\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\\s*\",\n                (re.I | re.M | re.S),\n            ),\n            \"</b>{}\",\n            {\"html\": \"<br/>\"},\n        ),\n        # Italic\n        (\n            re.compile(\n                r\"<\\s*(caption|em)([^a-z0-9>][^>]*)?>\", (re.I | re.M | re.S)\n            ),\n            \"<i>\",\n            {},\n        ),\n        (\n            re.compile(\n                r\"<\\s*/\\s*(caption|em)([^a-z0-9>][^>]*)?>\",\n                (re.I | re.M | re.S),\n            ),\n            \"</i>\",\n            {},\n        ),\n        # Bullet Lists\n        (\n            re.compile(r\"<\\s*li([^a-z0-9>][^>]*)?>\\s*\", (re.I | re.M | re.S)),\n            \" -\",\n            {},\n        ),\n        # New Lines\n        (\n            re.compile(\n                r\"\\s*<\\s*/?\\s*(ol|ul|br|hr)\\s*/?>\\s*\", (re.I | re.M | re.S)\n            ),\n            \"\\r\\n\",\n            {},\n        ),\n        (\n            re.compile(\n                r\"\\s*<\\s*/\\s*(br|p|hr|li|div)([^a-z0-9>][^>]*)?>\\s*\",\n                (re.I | re.M | re.S),\n            ),\n            \"\\r\\n\",\n            {},\n        ),\n        # HTML Spaces (&nbsp;) and tabs (&emsp;) aren't supported\n        # See https://core.telegram.org/bots/api#html-style\n        (re.compile(r\"\\&nbsp;?\", re.I), \" \", {}),\n        # Tabs become 3 spaces\n        (re.compile(r\"\\&emsp;?\", re.I), \"   \", {}),\n        # Some characters get re-escaped by the Telegram upstream\n        # service so we need to convert these back,\n        (re.compile(r\"\\&apos;?\", re.I), \"'\", {}),\n        (re.compile(r\"\\&quot;?\", re.I), '\"', {}),\n        # New line cleanup\n        (re.compile(r\"\\r*\\n[\\r\\n]+\", re.I), \"\\r\\n\", {}),\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"bot_token\": {\n                \"name\": _(\"Bot Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                # Token required as part of the API request, allow the word\n                # 'bot' infront of it\n                \"regex\": (r\"^(bot)?(?P<key>[0-9]+:[a-z0-9_-]+)$\", \"i\"),\n            },\n            \"target_user\": {\n                \"name\": _(\"Target Chat ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n                \"regex\": (r\"^((-?[0-9]{1,32})|([a-z_-][a-z0-9_-]+))$\", \"i\"),\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"include_image\",\n            },\n            \"detect\": {\n                \"name\": _(\"Detect Bot Owner\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"detect_owner\",\n            },\n            \"silent\": {\n                \"name\": _(\"Silent Notification\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"preview\": {\n                \"name\": _(\"Web Page Preview\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"topic\": {\n                \"name\": _(\"Topic Thread ID\"),\n                \"type\": \"int\",\n            },\n            \"thread\": {\n                \"alias_of\": \"topic\",\n            },\n            \"mdv\": {\n                \"name\": _(\"Markdown Version\"),\n                \"type\": \"choice:string\",\n                \"values\": (\"v1\", \"v2\"),\n                \"default\": \"v1\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"content\": {\n                \"name\": _(\"Content Placement\"),\n                \"type\": \"choice:string\",\n                \"values\": TELEGRAM_CONTENT_PLACEMENT,\n                \"default\": TelegramContentPlacement.BEFORE,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        bot_token,\n        targets,\n        detect_owner=True,\n        include_image=False,\n        silent=None,\n        preview=None,\n        topic=None,\n        content=None,\n        mdv=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Telegram Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.bot_token = validate_regex(\n            bot_token, *self.template_tokens[\"bot_token\"][\"regex\"], fmt=\"{key}\"\n        )\n        if not self.bot_token:\n            err = f\"The Telegram Bot Token specified ({bot_token}) is invalid.\"\n            self.logger.warning(err)\n            raise TypeError(err)\n\n        # Get our Markdown Version\n        self.markdown_ver = (\n            TELEGRAM_MARKDOWN_VERSION_MAP[\n                NotifyTelegram.template_args[\"mdv\"][\"default\"]\n            ]\n            if mdv is None\n            else next(\n                (\n                    v\n                    for k, v in TELEGRAM_MARKDOWN_VERSION_MAP.items()\n                    if str(mdv).lower().startswith(k)\n                ),\n                TELEGRAM_MARKDOWN_VERSION_MAP[\n                    NotifyTelegram.template_args[\"mdv\"][\"default\"]\n                ],\n            )\n        )\n\n        # Define whether or not we should make audible alarms\n        self.silent = (\n            self.template_args[\"silent\"][\"default\"]\n            if silent is None\n            else bool(silent)\n        )\n\n        # Define whether or not we should display a web page preview\n        self.preview = (\n            self.template_args[\"preview\"][\"default\"]\n            if preview is None\n            else bool(preview)\n        )\n\n        # Setup our content placement\n        self.content = (\n            self.template_args[\"content\"][\"default\"]\n            if not isinstance(content, str)\n            else content.lower()\n        )\n        if self.content and self.content not in TELEGRAM_CONTENT_PLACEMENT:\n            msg = f\"The content placement specified ({content}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        if topic:\n            try:\n                self.topic = int(topic)\n\n            except (TypeError, ValueError) as exc:\n                # Not a valid integer; ignore entry\n                err = f\"The Telegram Topic ID specified ({topic}) is invalid.\"\n                self.logger.warning(err)\n                raise TypeError(err) from exc\n        else:\n            # No Topic Thread\n            self.topic = None\n\n        # if detect_owner is set to True, we will attempt to determine who\n        # the bot owner is based on the first person who messaged it.  This\n        # is not a fool proof way of doing things as over time Telegram removes\n        # the message history for the bot.  So what appears (later on) to be\n        # the first message to it, maybe another user who sent it a message\n        # much later.  Users who set this flag should update their Apprise\n        # URL later to directly include the user that we should message.\n        self.detect_owner = detect_owner\n\n        # Parse our list\n        self.targets = []\n        for target in parse_list(targets):\n            results = IS_CHAT_ID_RE.match(target)\n            if not results:\n                self.logger.warning(\n                    f\"Dropped invalid Telegram chat/group ({target}) \"\n                    \"specified.\",\n                )\n\n                # Ensure we don't fall back to owner detection\n                self.detect_owner = False\n                continue\n\n            if results.group(\"topic\"):\n                topic = int(\n                    results.group(\"topic\")\n                    if results.group(\"topic\")\n                    else self.topic\n                )\n            else:\n                # Default (if one set)\n                topic = self.topic\n\n            if results.group(\"name\") is not None:\n                # Name\n                self.targets.append(\n                    (\"@{}\".format(results.group(\"name\")), topic)\n                )\n\n            else:  # ID\n                self.targets.append((int(results.group(\"idno\")), topic))\n\n        # Track whether or not we want to send an image with our notification\n        # or not.\n        self.include_image = include_image\n\n    def send_media(self, target, notify_type, payload=None, attach=None):\n        \"\"\"Sends a sticker based on the specified notify type.\"\"\"\n\n        # Prepare our Headers\n        if payload is None:\n            payload = {}\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        # Our function name and payload are determined on the path\n        function_name = \"SendPhoto\"\n        key = \"photo\"\n        path = None\n\n        if isinstance(attach, AttachBase):\n            if not attach:\n                # We could not access the attachment\n                self.logger.error(\n                    f\"Could not access attachment {attach.url(privacy=True)}.\"\n                )\n                return False\n\n            self.logger.debug(\n                f\"Posting Telegram attachment {attach.url(privacy=True)}\"\n            )\n\n            # Store our path to our file\n            path = attach.path\n            file_name = attach.name\n            mimetype = attach.mimetype\n\n            # Process our attachment\n            function_name, key = next(\n                (x[\"function_name\"], x[\"key\"])\n                for x in self.mime_lookup\n                if x[\"regex\"].match(mimetype)\n            )  # pragma: no cover\n\n        else:\n            attach = self.image_path(notify_type) if attach is None else attach\n            if attach is None:\n                # Nothing specified to send\n                return True\n\n            # Take on specified attachent as path\n            path = attach\n            file_name = os.path.basename(path)\n\n        url = f\"{self.notify_url}{self.bot_token}/{function_name}\"\n\n        # Always call throttle before any remote server i/o is made;\n        # Telegram throttles to occur before sending the image so that\n        # content can arrive together.\n        self.throttle()\n\n        # Extract our target\n        chat_id, topic = target\n\n        payload[\"chat_id\"] = chat_id\n        if topic:\n            payload[\"message_thread_id\"] = topic\n\n        try:\n            with open(path, \"rb\") as f:\n                # Configure file payload (for upload)\n                files = {key: (file_name, f)}\n\n                self.logger.debug(\n                    f\"Telegram attachment POST URL: {url} \"\n                    f\"(cert_verify={self.verify_certificate!r})\"\n                )\n\n                r = requests.post(\n                    url,\n                    headers=headers,\n                    files=files,\n                    data=payload,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyTelegram.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Telegram attachment: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    return False\n\n                # Content was sent successfully if we got here\n                return True\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A connection error occurred posting Telegram attachment.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n        except OSError:\n            # IOError is present for backwards compatibility with Python\n            # versions older then 3.3.  >= 3.3 throw OSError now.\n\n            # Could not open and/or read the file; this is not a problem since\n            # we scan a lot of default paths.\n            self.logger.error(f\"File can not be opened for read: {path}\")\n\n        return False\n\n    def detect_bot_owner(self):\n        \"\"\"Takes a bot and attempts to detect it's chat id from that.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        url = \"{}{}/{}\".format(self.notify_url, self.bot_token, \"getUpdates\")\n\n        self.logger.debug(\n            f\"Telegram User Detection POST URL: {url} \"\n            f\"(cert_verify={self.verify_certificate!r})\"\n        )\n\n        # Track our response object\n        response = None\n\n        try:\n            r = requests.post(\n                url,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyTelegram.http_response_code_lookup(\n                    r.status_code\n                )\n\n                try:\n                    # Try to get the error message if we can:\n                    error_msg = loads(r.content).get(\"description\", \"unknown\")\n\n                except (AttributeError, TypeError, ValueError):\n                    # ValueError = r.content is Unparsable\n                    # TypeError = r.content is None\n                    # AttributeError = r is None\n                    error_msg = None\n\n                if error_msg:\n                    self.logger.warning(\n                        \"Failed to detect the Telegram user: \"\n                        f\"({r.status_code}) {error_msg}.\"\n                    )\n\n                else:\n                    self.logger.warning(\n                        \"Failed to detect the Telegram user: \"\n                        \"{}{}error={}.\".format(\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                return 0\n\n            # Load our response and attempt to fetch our userid\n            response = loads(r.content)\n\n        except (AttributeError, TypeError, ValueError):\n            # Our response was not the JSON type we had expected it to be\n            # - ValueError = r.content is Unparsable\n            # - TypeError = r.content is None\n            # - AttributeError = r is None\n            self.logger.warning(\n                \"A communication error occurred detecting the Telegram User.\"\n            )\n            return 0\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A connection error occurred detecting the Telegram User.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n            return 0\n\n        # A Response might look something like this:\n        # {\n        #    \"ok\":true,\n        #    \"result\":[{\n        #      \"update_id\":645421321,\n        #      \"message\":{\n        #        \"message_id\":1,\n        #        \"from\":{\n        #          \"id\":532389719,\n        #          \"is_bot\":false,\n        #          \"first_name\":\"Chris\",\n        #          \"language_code\":\"en-US\"\n        #        },\n        #      \"chat\":{\n        #        \"id\":532389719,\n        #        \"first_name\":\"Chris\",\n        #        \"type\":\"private\"\n        #      },\n        #      \"date\":1519694394,\n        #      \"text\":\"/start\",\n        #      \"entities\":[{\"offset\":0,\"length\":6,\"type\":\"bot_command\"}]}}]\n\n        if response.get(\"ok\", False):\n            for entry in response.get(\"result\", []):\n                if \"message\" in entry and \"from\" in entry[\"message\"]:\n                    id_ = entry[\"message\"][\"from\"].get(\"id\", 0)\n                    user = entry[\"message\"][\"from\"].get(\"first_name\")\n                    self.logger.info(\n                        \"Detected Telegram user %s (userid=%d)\", user, id_\n                    )\n                    # Return our detected userid\n                    self.store.set(\"bot_owner\", id_)\n                    return id_\n\n        self.logger.warning(\n            \"Failed to detect a Telegram user; \"\n            \"try sending your bot a message first.\"\n        )\n        return 0\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        body_format=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Telegram Notification.\"\"\"\n\n        if len(self.targets) == 0 and self.detect_owner:\n            id_ = self.store.get(\"bot_owner\") or self.detect_bot_owner()\n            if id_:\n                # Permanently store our id in our target list for next time\n                self.targets.append((str(id_), self.topic))\n                self.logger.info(\n                    \"Update your Telegram Apprise URL to read: \"\n                    f\"{self.url(privacy=True)}\"\n                )\n\n        if len(self.targets) == 0:\n            self.logger.warning(\"There were not Telegram chat_ids to notify.\")\n            return False\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        url = \"{}{}/{}\".format(self.notify_url, self.bot_token, \"sendMessage\")\n\n        payload_ = {\n            # Notification Audible Control\n            \"disable_notification\": self.silent,\n            # Display Web Page Preview (if possible)\n            \"disable_web_page_preview\": not self.preview,\n        }\n\n        # Prepare Message Body\n        if self.notify_format == NotifyFormat.MARKDOWN:\n\n            if (\n                body_format not in (None, NotifyFormat.MARKDOWN)\n                and self.markdown_ver == TelegramMarkdownVersion.TWO\n            ):\n                # Telegram Markdown v2 is not very accomodating to some\n                # characters such as the hashtag (#) which is fine in v1.\n                # To try and be accomodating we escape them in advance\n                # See: https://stackoverflow.com/a/69892704/355584\n                # Also: https://core.telegram.org/bots/api#markdownv2-style\n                body = re.sub(r\"(?<!\\\\)([_*[\\]()~`>#+=|{}.!-])\", r\"\\\\\\1\", body)\n\n            payload_[\"parse_mode\"] = self.markdown_ver\n            payload_[\"text\"] = body\n\n        else:  # HTML\n\n            # Use Telegram's HTML mode\n            payload_[\"parse_mode\"] = \"HTML\"\n            for r, v, m in self.__telegram_escape_html_entries:\n\n                if \"html\" in m:\n                    # Handle special cases where we need to alter new lines\n                    # for presentation purposes\n                    v = v.format(\n                        m[\"html\"]\n                        if body_format\n                        in (NotifyFormat.HTML, NotifyFormat.MARKDOWN)\n                        else \"\"\n                    )\n\n                body = r.sub(v, body)\n\n            # Prepare our payload based on HTML or TEXT\n            payload_[\"text\"] = body\n\n        # Prepare our caption payload\n        caption_payload = (\n            {\n                \"caption\": payload_[\"text\"],\n                \"show_caption_above_media\": (\n                    self.content == TelegramContentPlacement.BEFORE\n                ),\n                \"parse_mode\": payload_[\"parse_mode\"],\n            }\n            if attach\n            and body\n            and len(payload_.get(\"text\", \"\")) < self.telegram_caption_maxlen\n            else {}\n        )\n\n        # Handle payloads without a body specified (but an attachment present)\n        attach_content = (\n            TelegramContentPlacement.AFTER\n            if not body or caption_payload\n            else self.content\n        )\n\n        # Create a copy of the chat_ids list\n        targets = list(self.targets)\n        while len(targets):\n            target = targets.pop(0)\n            chat_id, topic = target\n\n            # Printable chat_id details\n            pchat_id = f\"{chat_id}\" if not topic else f\"{chat_id}:{topic}\"\n\n            payload = payload_.copy()\n            payload[\"chat_id\"] = chat_id\n            if topic:\n                payload[\"message_thread_id\"] = topic\n\n            if self.include_image is True and not self.send_media(\n                target, notify_type\n            ):\n                # We failed to send the image associated with our\n                # notify_type\n                self.logger.warning(\n                    \"Failed to send Telegram attachment to {}.\", pchat_id\n                )\n\n            if (\n                attach\n                and self.attachment_support\n                and attach_content == TelegramContentPlacement.AFTER\n            ):\n                # Send our attachments now (if specified and if it exists)\n                if not self._send_attachments(\n                    target,\n                    notify_type=notify_type,\n                    payload=caption_payload,\n                    attach=attach,\n                ):\n\n                    has_error = True\n                    continue\n\n                if not body:\n                    # Nothing more to do; move along to the next attachment\n                    continue\n\n            if caption_payload:\n                # nothing further to do; move along to the next attachment\n                continue\n\n            # Always call throttle before any remote server i/o is made;\n            # Telegram throttles to occur before sending the image so that\n            # content can arrive together.\n            self.throttle()\n\n            self.logger.debug(\n                f\"Telegram POST URL: {url} \"\n                f\"(cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Telegram Payload: {payload!s}\")\n\n            try:\n                r = requests.post(\n                    url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyTelegram.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    try:\n                        # Try to get the error message if we can:\n                        error_msg = loads(r.content).get(\n                            \"description\", \"unknown\"\n                        )\n\n                    except (AttributeError, TypeError, ValueError):\n                        # ValueError = r.content is Unparsable\n                        # TypeError = r.content is None\n                        # AttributeError = r is None\n                        error_msg = None\n\n                    self.logger.warning(\n                        f\"Failed to send Telegram notification to {pchat_id}: \"\n                        f\"{error_msg if error_msg else status_str}, \"\n                        f\"error={r.status_code}.\"\n                    )\n\n                    self.logger.debug(f\"Response Details:\\r\\n{r.content}\")\n\n                    # Flag our error\n                    has_error = True\n                    continue\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A connection error occurred sending Telegram:{pchat_id} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Flag our error\n                has_error = True\n                continue\n\n            self.logger.info(\"Sent Telegram notification.\")\n\n            if (\n                attach\n                and self.attachment_support\n                and attach_content == TelegramContentPlacement.BEFORE\n                and not self._send_attachments(\n                    target=target, notify_type=notify_type, attach=attach\n                )\n            ):\n                # Send our attachments now (if specified and if it exists) as\n                # it was identified to send the content before the attachments\n                # which is now done.\n\n                has_error = True\n                continue\n\n        return not has_error\n\n    def _send_attachments(self, target, notify_type, attach, payload=None):\n        \"\"\"Sends our attachments.\"\"\"\n        if payload is None:\n            payload = {}\n        has_error = False\n        # Send our attachments now (if specified and if it exists)\n        for no, attachment in enumerate(attach, start=1):\n            payload = payload if payload and no == 1 else {}\n            payload.update({\n                \"title\": (\n                    attachment.name if attachment.name else f\"file{no:03}.dat\"\n                )\n            })\n\n            if not self.send_media(\n                target, notify_type, payload=payload, attach=attachment\n            ):\n\n                # We failed; don't continue\n                has_error = True\n                break\n\n            self.logger.info(f\"Sent Telegram attachment: {attachment}.\")\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.bot_token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": self.include_image,\n            \"detect\": \"yes\" if self.detect_owner else \"no\",\n            \"silent\": \"yes\" if self.silent else \"no\",\n            \"preview\": \"yes\" if self.preview else \"no\",\n            \"content\": self.content,\n            \"mdv\": TELEGRAM_MARKDOWN_VERSIONS[self.markdown_ver],\n        }\n\n        if self.topic:\n            params[\"topic\"] = self.topic\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        targets = []\n        for chat_id, topic_ in self.targets:\n            topic = topic_ if topic_ else self.topic\n\n            targets.append(\n                \"\".join([\n                    (\n                        NotifyTelegram.quote(f\"{chat_id}\", safe=\"@\")\n                        if isinstance(chat_id, str)\n                        else f\"{chat_id}\"\n                    ),\n                    \"\" if not topic else f\":{topic}\",\n                ])\n            )\n\n        # No need to check the user token because the user automatically gets\n        # appended into the list of chat ids\n        return \"{schema}://{bot_token}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            bot_token=self.pprint(self.bot_token, privacy, safe=\"\"),\n            targets=\"/\".join(targets),\n            params=NotifyTelegram.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return 1 if not self.targets else len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        # This is a dirty hack; but it's the only work around to tgram://\n        # messages since the bot_token has a colon in it. It invalidates a\n        # normal URL.\n\n        # This hack searches for this bogus URL and corrects it so we can\n        # properly load it further down. The other alternative is to ask users\n        # to actually change the colon into a slash (which will work too), but\n        # it's more likely to cause confusion... So this is the next best thing\n        # we also check for %3A (incase the URL is encoded) as %3A == :\n        try:\n            tgram = re.match(\n                rf\"(?P<protocol>{NotifyTelegram.secure_protocol}://)\"\n                r\"(bot)?(?P<prefix>([a-z0-9_-]+)\"\n                r\"(:[a-z0-9_-]+)?@)?(?P<btoken_a>[0-9]+)(:|%3A)+\"\n                r\"(?P<remaining>.*)$\",\n                url,\n                re.I,\n            )\n\n        except (TypeError, AttributeError):\n            # url is bad; force tgram to be None\n            tgram = None\n\n        if not tgram:\n            # Content is simply not parseable\n            return None\n\n        if tgram.group(\"prefix\"):\n            # Try again\n            results = NotifyBase.parse_url(\n                \"{}{}{}/{}\".format(\n                    tgram.group(\"protocol\"),\n                    tgram.group(\"prefix\"),\n                    tgram.group(\"btoken_a\"),\n                    tgram.group(\"remaining\"),\n                ),\n                verify_host=False,\n            )\n\n        else:\n            # Try again\n            results = NotifyBase.parse_url(\n                \"{}{}/{}\".format(\n                    tgram.group(\"protocol\"),\n                    tgram.group(\"btoken_a\"),\n                    tgram.group(\"remaining\"),\n                ),\n                verify_host=False,\n            )\n\n        # The first token is stored in the hostname\n        bot_token_a = NotifyTelegram.unquote(results[\"host\"])\n\n        # Get a nice unquoted list of path entries\n        entries = NotifyTelegram.split_path(results[\"fullpath\"])\n\n        # Now fetch the remaining tokens\n        bot_token_b = entries.pop(0)\n\n        bot_token = f\"{bot_token_a}:{bot_token_b}\"\n\n        # Store our chat ids (as these are the remaining entries)\n        results[\"targets\"] = entries\n\n        # content to be displayed 'before' or 'after' attachments\n        if \"content\" in results[\"qsd\"] and len(results[\"qsd\"][\"content\"]):\n            results[\"content\"] = results[\"qsd\"][\"content\"]\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyTelegram.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # Store our bot token\n        results[\"bot_token\"] = bot_token\n\n        # Support Markdown Version\n        if \"mdv\" in results[\"qsd\"] and len(results[\"qsd\"][\"mdv\"]):\n            results[\"mdv\"] = results[\"qsd\"][\"mdv\"]\n\n        # Support Thread Topic\n        if \"topic\" in results[\"qsd\"] and len(results[\"qsd\"][\"topic\"]):\n            results[\"topic\"] = results[\"qsd\"][\"topic\"]\n\n        elif \"thread\" in results[\"qsd\"] and len(results[\"qsd\"][\"thread\"]):\n            results[\"topic\"] = results[\"qsd\"][\"thread\"]\n\n        # Silent (Sends the message Silently); users will receive\n        # notification with no sound.\n        results[\"silent\"] = parse_bool(results[\"qsd\"].get(\"silent\", False))\n\n        # Show Web Page Preview\n        results[\"preview\"] = parse_bool(results[\"qsd\"].get(\"preview\", False))\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", False)\n        )\n\n        # Include images with our message\n        results[\"detect_owner\"] = parse_bool(\n            results[\"qsd\"].get(\"detect\", not results[\"targets\"])\n        )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/threema.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Create an account https://gateway.threema.ch/en/ if you don't already have\n# one\n#\n# Read more about Threema Gateway API here:\n#   - https://gateway.threema.ch/en/developer/api\n\nfrom itertools import chain\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_email, is_phone_no, parse_list, validate_regex\nfrom .base import NotifyBase\n\n\nclass ThreemaRecipientTypes:\n    \"\"\"The supported recipient specifiers.\"\"\"\n\n    THREEMA_ID = \"to\"\n    PHONE = \"phone\"\n    EMAIL = \"email\"\n\n\nclass NotifyThreema(NotifyBase):\n    \"\"\"A wrapper for Threema Gateway Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Threema Gateway\"\n\n    # The services URL\n    service_url = \"https://gateway.threema.ch/\"\n\n    # The default protocol\n    secure_protocol = \"threema\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/threema/\"\n\n    # Threema Gateway uses the http protocol with JSON requests\n    notify_url = \"https://msgapi.threema.ch/send_simple\"\n\n    # The maximum length of the body\n    body_maxlen = 3500\n\n    # No title support\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{gateway_id}@{secret}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"gateway_id\": {\n                \"name\": _(\"Gateway ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"map_to\": \"user\",\n            },\n            \"secret\": {\n                \"name\": _(\"API Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"target_email\": {\n                \"name\": _(\"Target Email\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_threema_id\": {\n                \"name\": _(\"Target Threema ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"gateway_id\",\n            },\n            \"gwid\": {\n                \"alias_of\": \"gateway_id\",\n            },\n            \"secret\": {\n                \"alias_of\": \"secret\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(self, secret=None, targets=None, **kwargs):\n        \"\"\"Initialize Threema Gateway Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Validate our params here.\n\n        if not self.user:\n            msg = \"Threema Gateway ID must be specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Verify our Gateway ID\n        if len(self.user) != 8:\n            msg = \"Threema Gateway ID must be 8 characters in length\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Verify our secret\n        self.secret = validate_regex(secret)\n        if not self.secret:\n            msg = f\"An invalid Threema API Secret ({secret}) was specified\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Parse our targets\n        self.targets = []\n\n        # Used for URL generation afterwards only\n        self.invalid_targets = []\n\n        for target in parse_list(targets, allow_whitespace=False):\n            if len(target) == 8:\n                # Store our user\n                self.targets.append((ThreemaRecipientTypes.THREEMA_ID, target))\n                continue\n\n            # Check if an email was defined\n            result = is_email(target)\n            if result:\n                # Store our user\n                self.targets.append(\n                    (ThreemaRecipientTypes.EMAIL, result[\"full_email\"])\n                )\n                continue\n\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if result:\n                # store valid phone number\n                self.targets.append(\n                    (ThreemaRecipientTypes.PHONE, result[\"full\"])\n                )\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid user/email/phone ({target}) specified\",\n            )\n            self.invalid_targets.append(target)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Threema Gateway Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\n                \"There were no Threema Gateway targets to notify\"\n            )\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n            \"Accept\": \"*/*\",\n        }\n\n        # Prepare our payload\n        payload_ = {\n            \"secret\": self.secret,\n            \"from\": self.user,\n            \"text\": body.encode(\"utf-8\"),\n        }\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        while len(targets):\n            # Get our target to notify\n            key, target = targets.pop(0)\n\n            # Prepare a payload object\n            payload = payload_.copy()\n\n            # Set Target\n            payload[key] = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Threema Gateway GET URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Threema Gateway Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    params=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyThreema.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Threema Gateway notification to {}: \"\n                        \"{}{}error={}\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                # We wee successful\n                self.logger.info(\n                    f\"Sent Threema Gateway notification to {target}\"\n                )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Threema\"\n                    f\" Gateway:{target} notification\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.user, self.secret)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        schemaStr = \"{schema}://{gatewayid}@{secret}/{targets}?{params}\"\n        return schemaStr.format(\n            schema=self.secure_protocol,\n            gatewayid=NotifyThreema.quote(self.user),\n            secret=self.pprint(\n                self.secret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            targets=\"/\".join(\n                chain(\n                    [\n                        NotifyThreema.quote(x[1], safe=\"@+\")\n                        for x in self.targets\n                    ],\n                    [\n                        NotifyThreema.quote(x, safe=\"@+\")\n                        for x in self.invalid_targets\n                    ],\n                )\n            ),\n            params=NotifyThreema.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        results[\"targets\"] = []\n\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            results[\"secret\"] = NotifyThreema.unquote(results[\"qsd\"][\"secret\"])\n\n        else:\n            results[\"secret\"] = NotifyThreema.unquote(results[\"host\"])\n\n        results[\"targets\"] += NotifyThreema.split_path(results[\"fullpath\"])\n\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"user\"] = NotifyThreema.unquote(results[\"qsd\"][\"from\"])\n\n        elif \"gwid\" in results[\"qsd\"] and len(results[\"qsd\"][\"gwid\"]):\n            results[\"user\"] = NotifyThreema.unquote(results[\"qsd\"][\"gwid\"])\n\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyThreema.parse_list(\n                results[\"qsd\"][\"to\"], allow_whitespace=False\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/twilio.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this service you will need a Twilio account to which you can get your\n# AUTH_TOKEN and ACCOUNT SID right from your console/dashboard at:\n#     https://www.twilio.com/console\n#\n# You will also need to send the SMS or do the call From a phone number or\n# account id name.\n\n# This is identified as the source (or where the SMS message or the call will\n# originate from). Activated phone numbers can be found on your dashboard here:\n#  - https://www.twilio.com/console/phone-numbers/incoming\n#\n# Alternatively, you can open your wallet and request a different Twilio\n# phone # from:\n#    https://www.twilio.com/console/phone-numbers/search\n#\n# or consider purchasing a short-code from here:\n#    https://www.twilio.com/docs/glossary/what-is-a-short-code\n#\nfrom json import loads\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n# Twilio Mode Detection\nMODE_DETECT_RE = re.compile(\n    r\"\\s*((?P<mode>[^:]+)\\s*:\\s*)?(?P<phoneno>.+)$\", re.I\n)\n\n\nclass TwilioNotificationMethod:\n    \"\"\"Twilio Notification Method.\"\"\"\n\n    SMS = \"sms\"\n    CALL = \"call\"\n\n\nTWILIO_NOTIFICATION_METHODS = (\n    TwilioNotificationMethod.SMS,\n    TwilioNotificationMethod.CALL,\n)\n\n\nclass TwilioMessageMode:\n    \"\"\"Twilio Message Mode.\"\"\"\n\n    # SMS/MMS\n    TEXT = \"T\"\n\n    # via WhatsApp\n    WHATSAPP = \"W\"\n\n\nclass NotifyTwilio(NotifyBase):\n    \"\"\"A wrapper for Twilio Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Twilio\"\n\n    # The services URL\n    service_url = \"https://www.twilio.com/\"\n\n    # All notification requests are secure\n    secure_protocol = \"twilio\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # the number of seconds undelivered messages should linger for\n    # in the Twilio queue\n    validity_period = 14400\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/twilio/\"\n\n    # Twilio uses the http protocol with JSON message requests\n    notify_sms_url = (\n        \"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json\"\n    )\n\n    # Twilio uses the http protocol with JSON call requests\n    notify_call_url = (\n        \"https://api.twilio.com/2010-04-01/Accounts/{sid}/Calls.json\"\n    )\n\n    # The maximum length of the sms body\n    body_sms_maxlen = 160\n\n    # The maximum length of the call body in xml format\n    body_call_maxlen = 4000\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{account_sid}:{auth_token}@{from_phone}\",\n        \"{schema}://{account_sid}:{auth_token}@{from_phone}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"account_sid\": {\n                \"name\": _(\"Account SID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^AC[a-f0-9]+$\", \"i\"),\n            },\n            \"auth_token\": {\n                \"name\": _(\"Auth Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^([a-z]+:)?\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^([a-z]+:)?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"short_code\": {\n                \"name\": _(\"Target Short Code\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[0-9]{5,6}$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"from_phone\",\n            },\n            \"sid\": {\n                \"alias_of\": \"account_sid\",\n            },\n            \"token\": {\n                \"alias_of\": \"auth_token\",\n            },\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"regex\": (r\"^SK[a-f0-9]+$\", \"i\"),\n            },\n            \"method\": {\n                \"name\": _(\"Notification Method: sms or call\"),\n                \"type\": \"choice:string\",\n                \"values\": TWILIO_NOTIFICATION_METHODS,\n                \"default\": TWILIO_NOTIFICATION_METHODS[0],\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        account_sid,\n        auth_token,\n        source,\n        targets=None,\n        apikey=None,\n        method=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Twilio Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # The Account SID associated with the account\n        self.account_sid = validate_regex(\n            account_sid, *self.template_tokens[\"account_sid\"][\"regex\"]\n        )\n        if not self.account_sid:\n            msg = (\n                f\"An invalid Twilio Account SID ({account_sid}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The Authentication Token associated with the account\n        self.auth_token = validate_regex(\n            auth_token, *self.template_tokens[\"auth_token\"][\"regex\"]\n        )\n        if not self.auth_token:\n            msg = (\n                \"An invalid Twilio Authentication Token \"\n                f\"({auth_token}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The API Key associated with the account (optional)\n        self.apikey = validate_regex(\n            apikey, *self.template_args[\"apikey\"][\"regex\"]\n        )\n\n        # Set notification method\n        if isinstance(method, str) and method:\n            self.method = next((\n                a\n                for a in TWILIO_NOTIFICATION_METHODS\n                if a.startswith(method.lower())\n            ),\n                None,\n            )\n\n            if self.method not in TWILIO_NOTIFICATION_METHODS:\n                msg = (\n                    f\"The Twilio notification method specified ({method}) \"\n                    \"is invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.method = self.template_args[\"method\"][\"default\"]\n\n        # Detect mode\n        result = MODE_DETECT_RE.match(source)\n        if not result:\n            msg = (\n                \"The Account (From) Phone # or Short-code specified \"\n                f\"({source}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # prepare our default mode to use for all numbers that follow in\n        # target definitions\n        self.default_mode = (\n            TwilioMessageMode.WHATSAPP\n            if result.group(\"mode\") and result.group(\"mode\")[0].lower() == \"w\"\n            else TwilioMessageMode.TEXT\n        )\n\n        # Check compatibility between notification method and mode\n        if self.method == TwilioNotificationMethod.CALL and \\\n                self.default_mode == TwilioMessageMode.WHATSAPP:\n            msg = (\n                \"The notification method Call is not valid along \"\n                \"message mode Whatsapp.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        result = is_phone_no(result.group(\"phoneno\"), min_len=5)\n        if not result:\n            msg = (\n                \"The Account (From) Phone # or Short-code specified \"\n                f\"({source}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store The Source Phone # and/or short-code\n        self.source = result[\"full\"]\n\n        if len(self.source) < 11 or len(self.source) > 14:\n            # https://www.twilio.com/docs/glossary/what-is-a-short-code\n            # A short code is a special 5 or 6 digit telephone number\n            # that's shorter than a full phone number.\n            if len(self.source) not in (5, 6):\n                msg = (\n                    \"The Account (From) Phone # specified \"\n                    f\"({source}) is invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            # else... it as a short code so we're okay\n\n        else:\n            # We're dealing with a phone number; so we need to just\n            # place a plus symbol at the end of it\n            self.source = f\"+{self.source}\"\n\n        # Parse our targets\n        self.targets = []\n\n        for entry in parse_phone_no(targets, prefix=True):\n            # Detect mode\n            # w: (or whatsapp:) will trigger whatsapp message otherwise\n            #   sms/mms as normal\n            result = MODE_DETECT_RE.match(entry)\n            mode = (\n                TwilioMessageMode.WHATSAPP\n                if result.group(\"mode\")\n                and result.group(\"mode\")[0].lower() == \"w\"\n                else self.default_mode\n            )\n\n            # Validate targets and drop bad ones:\n            result = is_phone_no(result.group(\"phoneno\"))\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({entry}) specified.\",\n                )\n                continue\n\n            # We can't use WhatsApp using short-codes as our source or\n            # for phone calls\n            if ((len(self.source) in (5, 6)\n                 or self.method == TwilioNotificationMethod.CALL)\n                    and mode is TwilioMessageMode.WHATSAPP):\n                self.logger.warning(\n                    f\"Dropped WhatsApp phone # ({entry}) because source\"\n                    \" provided is a short-code or because notification\"\n                    \" method is phone call.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append((mode, \"+{}\".format(result[\"full\"])))\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Twilio Notification.\"\"\"\n\n        if not self.targets and len(self.source) in (5, 6):\n            # Generate a warning since we're a short-code.  We need\n            # a number to message at minimum\n            self.logger.warning(\"There are no valid Twilio targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {}\n\n        # Prepare our Twilio URL and payload parameter according\n        # to notification method\n        if self.method == TwilioNotificationMethod.SMS:\n            url = self.notify_sms_url.format(sid=self.account_sid)\n            payload[\"Body\"] = body\n        else:\n            url = self.notify_call_url.format(sid=self.account_sid)\n            payload[\"Twiml\"] = body\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        # Set up our authentication. Prefer the API Key if provided.\n        auth = (self.apikey or self.account_sid, self.auth_token)\n\n        if len(targets) == 0 and self.method != TwilioNotificationMethod.CALL:\n            # No sources specified, use our own phone only with messages\n            targets.append((self.default_mode, self.source))\n\n        while len(targets):\n            # Get our target to notify\n            (mode, target) = targets.pop(0)\n\n            # Prepare our user\n            if mode is TwilioMessageMode.TEXT:\n                payload[\"From\"] = self.source\n                payload[\"To\"] = target\n\n            else:  # WhatsApp support (via Twilio)\n                payload[\"From\"] = f\"whatsapp:{self.source}\"\n                payload[\"To\"] = f\"whatsapp:{target}\"\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Twilio POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Twilio Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    url,\n                    auth=auth,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code not in (\n                    requests.codes.created,\n                    requests.codes.ok,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    # set up our status code to use\n                    status_code = r.status_code\n\n                    try:\n                        # Update our status response if we can\n                        json_response = loads(r.content)\n                        status_code = json_response.get(\"code\", status_code)\n                        status_str = json_response.get(\"message\", status_str)\n\n                    except (AttributeError, TypeError, ValueError):\n                        # ValueError = r.content is Unparsable\n                        # TypeError = r.content is None\n                        # AttributeError = r is None\n\n                        # We could not parse JSON response.\n                        # We will just use the status we already have.\n                        pass\n\n                    self.logger.warning(\n                        \"Failed to send Twilio notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Twilio notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending Twilio:{target} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def body_maxlen(self):\n        \"\"\"The maximum allowable characters allowed in the body per message.\n        It is dependent on the notification method.\"\"\"\n        return self.body_sms_maxlen \\\n            if self.method == TwilioNotificationMethod.SMS \\\n            else self.body_call_maxlen\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.account_sid,\n            self.auth_token,\n            self.source,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        params[\"method\"] = self.method\n\n        if self.apikey is not None:\n            # apikey specified; pass it back on the url\n            params[\"apikey\"] = self.apikey\n\n        return \"{schema}://{sid}:{token}@{source}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            sid=self.pprint(\n                self.account_sid, privacy, mode=PrivacyMode.Tail, safe=\"\"\n            ),\n            token=self.pprint(self.auth_token, privacy, safe=\"\"),\n            source=NotifyTwilio.quote(\n                (\n                    self.source\n                    if self.default_mode is TwilioMessageMode.TEXT\n                    else f\"w:{self.source}\"\n                ),\n                safe=\"\",\n            ),\n            targets=\"/\".join([\n                NotifyTwilio.quote(\n                    (x[1] if x[0] is TwilioMessageMode.TEXT else f\"w:{x[1]}\"),\n                    safe=\"\",\n                )\n                for x in self.targets\n            ]),\n            params=NotifyTwilio.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyTwilio.split_path(results[\"fullpath\"])\n\n        # The hostname is our source number\n        results[\"source\"] = NotifyTwilio.unquote(results[\"host\"])\n\n        # Get our account_side and auth_token from the user/pass config\n        results[\"account_sid\"] = NotifyTwilio.unquote(results[\"user\"])\n        results[\"auth_token\"] = NotifyTwilio.unquote(results[\"password\"])\n\n        # Auth Token\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Extract the account sid from an argument\n            results[\"auth_token\"] = NotifyTwilio.unquote(\n                results[\"qsd\"][\"token\"]\n            )\n\n        # Account SID\n        if \"sid\" in results[\"qsd\"] and len(results[\"qsd\"][\"sid\"]):\n            # Extract the account sid from an argument\n            results[\"account_sid\"] = NotifyTwilio.unquote(\n                results[\"qsd\"][\"sid\"]\n            )\n\n        # API Key\n        if \"apikey\" in results[\"qsd\"] and len(results[\"qsd\"][\"apikey\"]):\n            results[\"apikey\"] = results[\"qsd\"][\"apikey\"]\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyTwilio.unquote(results[\"qsd\"][\"from\"])\n        if \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifyTwilio.unquote(results[\"qsd\"][\"source\"])\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyTwilio.parse_phone_no(\n                results[\"qsd\"][\"to\"], prefix=True\n            )\n\n        # Notification method\n        if \"method\" in results[\"qsd\"] and len(results[\"qsd\"][\"method\"]):\n            # Extract the notification method from an argument\n            results[\"method\"] = NotifyTwilio.unquote(results[\"qsd\"][\"method\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/twist.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n#\n# All of the documentation needed to work with the Twist API can be found\n# here: https://developer.twist.com/v3/\n\nfrom itertools import chain\nfrom json import loads\nimport re\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_email, parse_list\nfrom .base import NotifyBase\n\n# A workspace can also be interpreted as a team name too!\nIS_CHANNEL = re.compile(\n    r\"^#?(?P<name>((?P<workspace>[A-Za-z0-9_-]+):)?\"\n    r\"(?P<channel>[^\\s]{1,64}))$\"\n)\n\nIS_CHANNEL_ID = re.compile(\n    r\"^(?P<name>((?P<workspace>[0-9]+):)?(?P<channel>[0-9]+))$\"\n)\n\n# Used to break apart list of potential tags by their delimiter\n# into a usable list.\nLIST_DELIM = re.compile(r\"[ \\t\\r\\n,\\\\/]+\")\n\n\nclass NotifyTwist(NotifyBase):\n    \"\"\"A wrapper for Notify Twist Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Twist\"\n\n    # The services URL\n    service_url = \"https://twist.com\"\n\n    # The default secure protocol\n    secure_protocol = \"twist\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/twist/\"\n\n    # The maximum size of the message\n    body_maxlen = 1000\n\n    # Default to markdown\n    notify_format = NotifyFormat.MARKDOWN\n\n    # The default Notification URL to use\n    api_url = \"https://api.twist.com/api/v3/\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.2\n\n    # The default channel to notify if no targets are specified\n    default_notification_channel = \"general\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{password}:{email}\",\n        \"{schema}://{password}:{email}/{targets}\",\n    )\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"email\": {\n                \"name\": _(\"Email\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"target_channel\": {\n                \"name\": _(\"Target Channel\"),\n                \"type\": \"string\",\n                \"prefix\": \"#\",\n                \"map_to\": \"targets\",\n            },\n            \"target_channel_id\": {\n                \"name\": _(\"Target Channel ID\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(self, email=None, targets=None, **kwargs):\n        \"\"\"Initialize Notify Twist Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Initialize channels list\n        self.channels = set()\n\n        # Initialize Channel ID which are stored as:\n        #   <workspace_id>:<channel_id>\n        self.channel_ids = set()\n\n        # The token is None if we're not logged in and False if we\n        # failed to log in.  Otherwise it is set to the actual token\n        self.token = None\n\n        # Our default workspace (associated with our token)\n        self.default_workspace = None\n\n        # A set of all of the available workspaces\n        self._cached_workspaces = set()\n\n        # A mapping of channel names, the layout is as follows:\n        #  {\n        #     <workspace_id>: {\n        #          <channel_name>: <channel_id>,\n        #          <channel_name>: <channel_id>,\n        #          ...\n        #     },\n        #     <workspace2_id>: {\n        #          <channel_name>: <channel_id>,\n        #          <channel_name>: <channel_id>,\n        #          ...\n        #     },\n        #  }\n        self._cached_channels = {}\n\n        # Initialize our Email Object\n        self.email = email if email else f\"{self.user}@{self.host}\"\n\n        # Check if it is valid\n        result = is_email(self.email)\n        if not result:\n            # let outer exception handle this\n            msg = f\"The Twist Auth email specified ({self.email}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Re-assign email based on what was parsed\n        self.email = result[\"full_email\"]\n        if email:\n            # Force user/host to be that of the defined email for\n            # consistency. This is very important for those initializing\n            # this object with the the email object would could potentially\n            # cause inconsistency to contents in the NotifyBase() object\n            self.user = result[\"user\"]\n            self.host = result[\"domain\"]\n\n        if not self.password:\n            msg = f\"No Twist password was specified with account: {self.email}\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Validate recipients and drop bad ones:\n        for recipient in parse_list(targets):\n            result = IS_CHANNEL_ID.match(recipient)\n            if result:\n                # store valid channel id\n                self.channel_ids.add(result.group(\"name\"))\n                continue\n\n            result = IS_CHANNEL.match(recipient)\n            if result:\n                # store valid device\n                self.channels.add(result.group(\"name\").lower())\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid channel/id ({recipient}) specified.\",\n            )\n\n        if len(self.channels) + len(self.channel_ids) == 0:\n            # Notify our default channel\n            self.channels.add(self.default_notification_channel)\n            self.logger.warning(\n                \"Added default notification channel \"\n                f\"{self.default_notification_channel}\"\n            )\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.user,\n            self.password,\n            self.host,\n            self.port,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return (\n            \"{schema}://{password}:{user}@{host}/{targets}/?{params}\".format(\n                schema=self.secure_protocol,\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                user=self.quote(self.user, safe=\"\"),\n                host=self.host,\n                targets=\"/\".join([\n                    NotifyTwist.quote(x, safe=\"\")\n                    for x in chain(\n                        # Channels are prefixed with a pound/hashtag symbol\n                        [f\"#{x}\" for x in self.channels],\n                        # Channel IDs\n                        self.channel_ids,\n                    )\n                ]),\n                params=NotifyTwist.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.channels) + len(self.channel_ids)\n\n    def login(self):\n        \"\"\"A simple wrapper to authenticate with the Twist Server.\"\"\"\n\n        # Prepare our payload\n        payload = {\n            \"email\": self.email,\n            \"password\": self.password,\n        }\n\n        # Reset our default workspace\n        self.default_workspace = None\n\n        # Reset our cached objects\n        self._cached_workspaces = set()\n        self._cached_channels = {}\n\n        # Send Login Information\n        postokay, response = self._fetch(\n            \"users/login\",\n            payload=payload,\n            # We set this boolean so internal recursion doesn't take place.\n            login=True,\n        )\n\n        if not postokay or not response:\n            # Setting this variable to False as a way of letting us know\n            # we failed to authenticate on our last attempt\n            self.token = False\n            return False\n\n        # Our response object looks like this (content has been altered for\n        # presentation purposes):\n        # {\n        #     \"contact_info\": null,\n        #     \"profession\": null,\n        #     \"timezone\": \"UTC\",\n        #     \"avatar_id\": null,\n        #     \"id\": 123456,\n        #     \"first_name\": \"Jordan\",\n        #     \"comet_channel\":\n        #         \"124371-34be423219130343030d4ec0a3dabbbbbe565eee\",\n        #     \"restricted\": false,\n        #     \"default_workspace\": 92020,\n        #     \"snooze_dnd_end\": null,\n        #     \"email\": \"user@example.com\",\n        #     \"comet_server\": \"https://comet.twist.com\",\n        #     \"snooze_until\": null,\n        #     \"lang\": \"en\",\n        #     \"feature_flags\": [],\n        #     \"short_name\": \"Jordan P.\",\n        #     \"away_mode\": null,\n        #     \"time_format\": \"12\",\n        #     \"client_id\": \"cb01f37e-a5b2-13e9-ba2a-023a33d10dc0\",\n        #     \"removed\": false,\n        #     \"emails\": [\n        #         {\n        #             \"connected\": [],\n        #             \"email\": \"user@example.com\",\n        #             \"primary\": true\n        #         }\n        #     ],\n        #     \"scheduled_banners\": [\n        #         \"threads_3\",\n        #         \"threads_1\",\n        #         \"notification_permissions\",\n        #         \"search_1\",\n        #         \"messages_1\",\n        #         \"team_1\",\n        #         \"inbox_2\",\n        #         \"inbox_1\"\n        #     ],\n        #     \"snooze_dnd_start\": null,\n        #     \"name\": \"Jordan Peterson\",\n        #     \"off_days\": [],\n        #     \"bot\": false,\n        #     \"token\": \"2e82c1e4e8b0091fdaa34ff3972351821406f796\",\n        #     \"snoozed\": false,\n        #     \"setup_pending\": false,\n        #     \"date_format\": \"MM/DD/YYYY\"\n        # }\n\n        # Store our default workspace\n        self.default_workspace = response.get(\"default_workspace\")\n\n        # Acquire our token\n        self.token = response.get(\"token\")\n\n        self.logger.info(f\"Authenticated to Twist as {self.email}\")\n        return True\n\n    def logout(self):\n        \"\"\"A simple wrapper to log out of the server.\"\"\"\n\n        if not self.token:\n            # Nothing more to do\n            return True\n\n        # Send Logout Message\n        _postokay, _response = self._fetch(\"users/logout\")\n\n        # reset our token\n        self.token = None\n\n        # There is no need to handling failed log out attempts at this time\n        return True\n\n    def get_workspaces(self):\n        \"\"\"Returns all workspaces associated with this user account as a set.\n\n        This returned object is either an empty dictionary or one that\n        looks like this:\n           {\n             'workspace': <workspace_id>,\n             'workspace': <workspace_id>,\n             'workspace': <workspace_id>,\n           }\n\n        All workspaces are made lowercase for comparison purposes\n        \"\"\"\n        if not self.token and not self.login():\n            # Nothing more to do\n            return {}\n\n        postokay, response = self._fetch(\"workspaces/get\")\n        if not postokay or not response:\n            # We failed to retrieve\n            return {}\n\n        # The response object looks like so:\n        #   [\n        #     {\n        #       \"created_ts\": 1563044447,\n        #       \"name\": \"apprise\",\n        #       \"creator\": 123571,\n        #       \"color\": 1,\n        #       \"default_channel\": 13245,\n        #       \"plan\": \"free\",\n        #       \"default_conversation\": 63022,\n        #       \"id\": 12345\n        #     }\n        #   ]\n\n        # Knowing our response, we can iterate over each object and cache our\n        # object\n        result = {}\n        for entry in response:\n            result[entry.get(\"name\", \"\").lower()] = entry.get(\"id\", \"\")\n\n        return result\n\n    def get_channels(self, wid):\n        \"\"\"Simply returns the channel objects associated with the specified\n        workspace id.\n\n        This returned object is either an empty dictionary or one that\n        looks like this:\n           {\n             'channel1': <channel_id>,\n             'channel2': <channel_id>,\n             'channel3': <channel_id>,\n           }\n\n        All channels are made lowercase for comparison purposes\n        \"\"\"\n        if not self.token and not self.login():\n            # Nothing more to do\n            return {}\n\n        payload = {\"workspace_id\": wid}\n        postokay, response = self._fetch(\"channels/get\", payload=payload)\n\n        if not postokay or not isinstance(response, list):\n            # We failed to retrieve\n            return {}\n\n        # Response looks like this:\n        #  [\n        #    {\n        #      \"id\": 123,\n        #      \"name\": \"General\"\n        #      \"workspace_id\": 12345,\n        #      \"color\": 1,\n        #      \"description\": \"\",\n        #      \"archived\": false,\n        #      \"public\": true,\n        #      \"user_ids\": [\n        #        8754\n        #      ],\n        #      \"created_ts\": 1563044447,\n        #      \"creator\": 123571,\n        #    }\n        #  ]\n        #\n        # Knowing our response, we can iterate over each object and cache our\n        # object\n        result = {}\n        for entry in response:\n            result[entry.get(\"name\", \"\").lower()] = entry.get(\"id\", \"\")\n\n        return result\n\n    def _channel_migration(self):\n        \"\"\"A simple wrapper to get all of the current workspaces including the\n        default one.  This plays a role in what channel(s) get notified and\n        where.\n\n        A cache lookup has overhead, and is only required to be preformed if\n        the user specified channels by their string value\n        \"\"\"\n\n        if not self.token and not self.login():\n            # Nothing more to do\n            return False\n\n        if not len(self.channels):\n            # Nothing to do; take an early exit\n            return True\n\n        if (\n            self.default_workspace\n            and self.default_workspace not in self._cached_channels\n        ):\n            # Get our default workspace entries\n            self._cached_channels[self.default_workspace] = self.get_channels(\n                self.default_workspace\n            )\n\n        # initialize our error tracking\n        has_error = False\n\n        while len(self.channels):\n            # Pop our channel off of the stack\n            result = IS_CHANNEL.match(self.channels.pop())\n\n            # Populate our key variables\n            workspace = result.group(\"workspace\")\n            channel = result.group(\"channel\").lower()\n\n            # Acquire our workspace_id if we can\n            if workspace:\n                # We always work with the workspace in it's lowercase form\n                workspace = workspace.lower()\n\n                # A workspace was defined\n                if not len(self._cached_workspaces):\n                    # cache our workspaces; this only needs to be done once\n                    self._cached_workspaces = self.get_workspaces()\n\n                if workspace not in self._cached_workspaces:\n                    # not found\n                    self.logger.warning(\n                        f\"The Twist User {self.email} is not associated \"\n                        f\"with the Team {workspace}\"\n                    )\n\n                    # Toggle our return flag\n                    has_error = True\n                    continue\n\n                # Store the workspace id\n                workspace_id = self._cached_workspaces[workspace]\n\n            else:\n                # use default workspace\n                workspace_id = self.default_workspace\n\n            # Check to see if our channel exists in our default workspace\n            if (\n                workspace_id in self._cached_channels\n                and channel in self._cached_channels[workspace_id]\n            ):\n                # Store our channel ID\n                self.channel_ids.add(\n                    f\"{workspace_id}\"\n                    f\":{self._cached_channels[workspace_id][channel]}\"\n                )\n                continue\n\n            # if we reach here, we failed to add our channel\n            self.logger.warning(\n                \"The Channel #{} was not found{}.\".format(\n                    channel,\n                    \"\" if not workspace else f\" with Team {workspace}\",\n                )\n            )\n\n            # Toggle our return flag\n            has_error = True\n            continue\n\n        # There is no need to handling failed log out attempts at this time\n        return not has_error\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Twist Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        if not self.token and not self.login():\n            # We failed to authenticate - we're done\n            return False\n\n        if len(self.channels) > 0:\n            # Converts channels to their maped IDs if found; this is the only\n            # way to send notifications to Twist\n            self._channel_migration()\n\n        if not len(self.channel_ids):\n            # We have nothing to notify\n            self.logger.warning(\"There are no Twist targets to notify\")\n            return False\n\n        # Notify all of our identified channels\n        ids = list(self.channel_ids)\n        while len(ids) > 0:\n            # Retrieve our Channel Object\n            result = IS_CHANNEL_ID.match(ids.pop())\n\n            # We need both the workspace/team id and channel id\n            channel_id = int(result.group(\"channel\"))\n\n            # Prepare our payload\n            payload = {\n                \"channel_id\": channel_id,\n                \"title\": title,\n                \"content\": body,\n            }\n\n            postokay, _response = self._fetch(\n                \"threads/add\",\n                payload=payload,\n            )\n\n            # only toggle has_error flag if we had an error\n            if not postokay:\n                # Mark our failure\n                has_error = True\n                continue\n\n            # If we reach here, we were successful\n            self.logger.info(\n                \"Sent Twist notification to {}.\".format(result.group(\"name\"))\n            )\n\n        return not has_error\n\n    def _fetch(self, url, payload=None, method=\"POST\", login=False):\n        \"\"\"Wrapper to Twist API requests object.\"\"\"\n\n        # use what was specified, otherwise build headers dynamically\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        headers[\"Content-Type\"] = (\n            \"application/x-www-form-urlencoded; charset=utf-8\"\n        )\n\n        if self.token:\n            # Set our token\n            headers[\"Authorization\"] = f\"Bearer {self.token}\"\n\n        # Prepare our api url\n        api_url = f\"{self.api_url}{url}\"\n\n        # Some Debug Logging\n        self.logger.debug(\n            f\"Twist {method} URL: {api_url} \"\n            f\"(cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"Twist Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made;\n        self.throttle()\n\n        # Initialize a default value for our content value\n        content = {}\n\n        # acquire our request mode\n        fn = requests.post if method == \"POST\" else requests.get\n        try:\n            r = fn(\n                api_url,\n                data=payload,\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            # Get our JSON content if it's possible\n            try:\n                content = loads(r.content)\n\n            except (TypeError, ValueError, AttributeError):\n                # TypeError = r.content is not a String\n                # ValueError = r.content is Unparsable\n                # AttributeError = r.content is None\n                content = {}\n\n            # handle authentication errors where our token has just simply\n            # expired. The error response content looks like this:\n            #  {\n            #     \"error_code\": 200,\n            #     \"error_uuid\": \"af80bd0715434231a649f2258d7fb946\",\n            #     \"error_extra\": {},\n            #     \"error_string\": \"Invalid token\"\n            #  }\n            #\n            #  Authentication related codes:\n            #    120 = You are not logged in\n            #    200 = Invalid Token\n            #\n            #  Source: https://developer.twist.com/v3/#errors\n            #\n            #  We attempt to login again and retry the original request\n            #  if we aren't in the process of handling a login already\n            if (\n                r.status_code != requests.codes.ok\n                and login is False\n                and isinstance(content, dict)\n                and content.get(\"error_code\") in (120, 200)\n                and self.login()\n            ):\n\n                r = fn(\n                    api_url,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # Get our JSON content if it's possible\n                try:\n                    content = loads(r.content)\n\n                except (TypeError, ValueError, AttributeError):\n                    # TypeError = r.content is not a String\n                    # ValueError = r.content is Unparsable\n                    # AttributeError = r.content is None\n                    content = {}\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyTwist.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Twist {} to {}: {}error={}.\".format(\n                        method,\n                        api_url,\n                        \", \" if status_str else \"\",\n                        r.status_code,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                return (False, content)\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"Exception received when sending Twist {method} to {api_url}\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            return (False, content)\n\n        return (True, content)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        if not results.get(\"user\"):\n            # A username is required\n            return None\n\n        # Acquire our targets\n        results[\"targets\"] = NotifyTwist.split_path(results[\"fullpath\"])\n\n        if not results.get(\"password\"):\n            # Password is required; we will accept the very first entry on the\n            # path as a password instead\n            if len(results[\"targets\"]) == 0:\n                # No targets to get our password from\n                return None\n\n            # We need to requote contents since this variable will get\n            # unquoted later on in the process.  This step appears a bit\n            # hacky, but it allows us to support the password in this location\n            #   - twist://user@example.com/password\n            results[\"password\"] = NotifyTwist.quote(\n                results[\"targets\"].pop(0), safe=\"\"\n            )\n\n        else:\n            # Now we handle our format:\n            #    twist://password:email\n            #\n            # since URL logic expects\n            #    schema://user:password@host\n            #\n            # you can see how this breaks. The colon at the front delmits\n            #  passwords and you can see the twist:// url inverts what we\n            #  expect:\n            #    twist://password:user@example.com\n            #\n            # twist://abc123:bob@example.com using normal conventions would\n            # have interpreted 'bob' as the password and 'abc123' as the user.\n            # For the purpose of apprise simplifying this for us, we need to\n            # swap these arguments when we prepare the email.\n\n            password = results[\"user\"]\n            results[\"user\"] = results[\"password\"]\n            results[\"password\"] = password\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyTwist.parse_list(results[\"qsd\"][\"to\"])\n\n        return results\n\n    def __del__(self):\n        \"\"\"Destructor.\"\"\"\n        self.logout()\n"
  },
  {
    "path": "apprise/plugins/twitter.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# See https://developer.twitter.com/en/docs/direct-messages/\\\n#           sending-and-receiving/api-reference/new-event.html\nimport contextlib\nfrom copy import deepcopy\nfrom datetime import datetime, timezone\nfrom json import dumps, loads\nimport re\n\nimport requests\nfrom requests_oauthlib import OAuth1\n\nfrom ..attachment.base import AttachBase\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_bool, parse_list, validate_regex\nfrom .base import NotifyBase\n\nIS_USER = re.compile(r\"^\\s*@?(?P<user>[A-Z0-9_]+)$\", re.I)\n\n\nclass TwitterMessageMode:\n    \"\"\"Twitter Message Mode.\"\"\"\n\n    # DM (a Direct Message)\n    DM = \"dm\"\n\n    # A Public Tweet\n    TWEET = \"tweet\"\n\n\n# Define the types in a list for validation purposes\nTWITTER_MESSAGE_MODES = (\n    TwitterMessageMode.DM,\n    TwitterMessageMode.TWEET,\n)\n\n\nclass NotifyTwitter(NotifyBase):\n    \"\"\"A wrapper to Twitter Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Twitter\"\n\n    # The services URL\n    service_url = \"https://twitter.com/\"\n\n    # The default secure protocol is twitter.\n    secure_protocol = (\"x\", \"twitter\", \"tweet\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/twitter/\"\n\n    # Support attachments\n    attachment_support = True\n\n    # Do not set body_maxlen as it is set in a property value below\n    # since the length varies depending if we are doing a direct message\n    # or a tweet\n    # body_maxlen = see below @propery defined\n\n    # Twitter does have titles when creating a message\n    title_maxlen = 0\n\n    # Twitter API Reference To Acquire Someone's Twitter ID\n    twitter_lookup = \"https://api.twitter.com/1.1/users/lookup.json\"\n\n    # Twitter API Reference To Acquire Current Users Information\n    twitter_whoami = (\n        \"https://api.twitter.com/1.1/account/verify_credentials.json\"\n    )\n\n    # Twitter API Reference To Send A Private DM\n    twitter_dm = \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n\n    # Twitter API Reference To Send A Public Tweet\n    twitter_tweet = \"https://api.twitter.com/1.1/statuses/update.json\"\n\n    # it is documented on the site that the maximum images per tweet\n    # is 4 (unless it's a GIF, then it's only 1)\n    __tweet_non_gif_images_batch = 4\n\n    # Twitter Media (Attachment) Upload Location\n    twitter_media = \"https://upload.twitter.com/1.1/media/upload.json\"\n\n    # Twitter is kind enough to return how many more requests we're allowed to\n    # continue to make within it's header response as:\n    # X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our\n    #                    rate-limit to be reset.\n    # X-Rate-Limit-Remaining: an integer identifying how many requests we're\n    #                        still allow to make.\n    request_rate_per_sec = 0\n\n    # For Tracking Purposes\n    ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)\n\n    # Default to 1000; users can send up to 1000 DM's and 2400 tweets a day\n    # This value only get's adjusted if the server sets it that way\n    ratelimit_remaining = 1\n\n    templates = (\n        \"{schema}://{ckey}/{csecret}/{akey}/{asecret}\",\n        \"{schema}://{ckey}/{csecret}/{akey}/{asecret}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"ckey\": {\n                \"name\": _(\"Consumer Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"csecret\": {\n                \"name\": _(\"Consumer Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"akey\": {\n                \"name\": _(\"Access Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"asecret\": {\n                \"name\": _(\"Access Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"prefix\": \"@\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"mode\": {\n                \"name\": _(\"Message Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": TWITTER_MESSAGE_MODES,\n                \"default\": TwitterMessageMode.DM,\n            },\n            \"cache\": {\n                \"name\": _(\"Cache Results\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"batch\": {\n                \"name\": _(\"Batch Mode\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        ckey,\n        csecret,\n        akey,\n        asecret,\n        targets=None,\n        mode=None,\n        cache=True,\n        batch=True,\n        **kwargs,\n    ):\n        \"\"\"Initialize Twitter Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.ckey = validate_regex(ckey)\n        if not self.ckey:\n            msg = \"An invalid Twitter Consumer Key was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.csecret = validate_regex(csecret)\n        if not self.csecret:\n            msg = \"An invalid Twitter Consumer Secret was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.akey = validate_regex(akey)\n        if not self.akey:\n            msg = \"An invalid Twitter Access Key was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.asecret = validate_regex(asecret)\n        if not self.asecret:\n            msg = \"An invalid Access Secret was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our webhook mode\n        self.mode = (\n            self.template_args[\"mode\"][\"default\"]\n            if not isinstance(mode, str)\n            else mode.lower()\n        )\n\n        if mode and isinstance(mode, str):\n            self.mode = next(\n                (a for a in TWITTER_MESSAGE_MODES if a.startswith(mode)), None\n            )\n            if self.mode not in TWITTER_MESSAGE_MODES:\n                msg = (\n                    f\"The Twitter message mode specified ({mode}) is invalid.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n        else:\n            self.mode = self.template_args[\"mode\"][\"default\"]\n\n        # Set Cache Flag\n        self.cache = cache\n\n        # Prepare Image Batch Mode Flag\n        self.batch = batch\n\n        # Track any errors\n        has_error = False\n\n        # Identify our targets\n        self.targets = []\n        for target in parse_list(targets):\n            match = IS_USER.match(target)\n            if match and match.group(\"user\"):\n                self.targets.append(match.group(\"user\"))\n                continue\n\n            has_error = True\n            self.logger.warning(\n                f\"Dropped invalid Twitter user ({target}) specified.\",\n            )\n\n        if has_error and not self.targets:\n            # We have specified that we want to notify one or more individual\n            # and we failed to load any of them.  Since it's also valid to\n            # notify no one at all (which means we notify ourselves), it's\n            # important we don't switch from the users original intentions\n            self.targets = None\n\n        # Initialize our cache values\n        self._whoami_cache = None\n        self._user_cache = {}\n\n        return\n\n    def send(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attach=None,\n        **kwargs,\n    ):\n        \"\"\"Perform Twitter Notification.\"\"\"\n\n        if self.targets is None:\n            self.logger.warning(\"No valid Twitter targets to notify.\")\n            return False\n\n        # Build a list of our attachments\n        attachments = []\n\n        if attach and self.attachment_support:\n            # We need to upload our payload first so that we can source it\n            # in remaining messages\n            for no, attachment in enumerate(attach, start=1):\n\n                # Perform some simple error checking\n                if not attachment:\n                    # We could not access the attachment\n                    self.logger.error(\n                        \"Could not access attachment \"\n                        f\"'{attachment.url(privacy=True)}.\"\n                    )\n                    return False\n\n                if not re.match(r\"^image/.*\", attachment.mimetype, re.I):\n                    # Only support images at this time\n                    self.logger.warning(\n                        \"Ignoring unsupported Twitter attachment \"\n                        f\"{attachment.url(privacy=True)}.\"\n                    )\n                    continue\n\n                self.logger.debug(\n                    \"Preparing Twitter attachment \"\n                    f\"{attachment.url(privacy=True)}\"\n                )\n\n                # Upload our image and get our id associated with it\n                # see: https://developer.twitter.com/en/docs/twitter-api/v1/\\\n                #         media/upload-media/api-reference/post-media-upload\n                postokay, response = self._fetch(\n                    self.twitter_media,\n                    payload=attachment,\n                )\n\n                if not postokay:\n                    # We can't post our attachment\n                    return False\n\n                # Prepare our filename\n                filename = (\n                    attachment.name if attachment.name else f\"file{no:03}.dat\"\n                )\n\n                if not (\n                    isinstance(response, dict) and response.get(\"media_id\")\n                ):\n                    self.logger.debug(\n                        \"Could not attach the file to Twitter: %s (mime=%s)\",\n                        filename,\n                        attachment.mimetype,\n                    )\n                    continue\n\n                # If we get here, our output will look something like this:\n                # {\n                #   \"media_id\": 710511363345354753,\n                #   \"media_id_string\": \"710511363345354753\",\n                #   \"media_key\": \"3_710511363345354753\",\n                #   \"size\": 11065,\n                #   \"expires_after_secs\": 86400,\n                #   \"image\": {\n                #     \"image_type\": \"image/jpeg\",\n                #     \"w\": 800,\n                #     \"h\": 320\n                #   }\n                # }\n\n                response.update({\n                    # Update our response to additionally include the\n                    # attachment details\n                    \"file_name\": filename,\n                    \"file_mime\": attachment.mimetype,\n                    \"file_path\": attachment.path,\n                })\n\n                # Save our pre-prepared payload for attachment posting\n                attachments.append(response)\n\n        # - calls _send_tweet if the mode is set so\n        # - calls _send_dm (direct message) otherwise\n        return getattr(self, f\"_send_{self.mode}\")(\n            body=body,\n            title=title,\n            notify_type=notify_type,\n            attachments=attachments,\n            **kwargs,\n        )\n\n    def _send_tweet(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attachments=None,\n        **kwargs,\n    ):\n        \"\"\"Twitter Public Tweet.\"\"\"\n\n        # Error Tracking\n        has_error = False\n\n        payload = {\n            \"status\": body,\n        }\n\n        payloads = []\n        if not attachments:\n            payloads.append(payload)\n\n        else:\n            # Group our images if batch is set to do so\n            batch_size = (\n                1 if not self.batch else self.__tweet_non_gif_images_batch\n            )\n\n            # Track our batch control in our message generation\n            batches = []\n            batch = []\n            for attachment in attachments:\n                batch.append(str(attachment[\"media_id\"]))\n\n                # Twitter supports batching images together.  This allows\n                # the batching of multiple images together.  Twitter also\n                # makes it clear that you can't batch `gif` files; they need\n                # to be separate.  So the below preserves the ordering that\n                # a user passed their attachments in.  if 4-non-gif images\n                # are passed, they are all part of a single message.\n                #\n                # however, if they pass in image, gif, image, gif.  The\n                # gif's inbetween break apart the batches so this would\n                # produce 4 separate tweets.\n                #\n                # If you passed in, image, image, gif, image. <- This would\n                # produce 3 images (as the first 2 images could be lumped\n                # together as a batch)\n                if (\n                    not re.match(\n                        r\"^image/(png|jpe?g)\", attachment[\"file_mime\"], re.I\n                    )\n                    or len(batch) >= batch_size\n                ):\n                    batches.append(\",\".join(batch))\n                    batch = []\n\n            if batch:\n                batches.append(\",\".join(batch))\n\n            for no, media_ids in enumerate(batches):\n                payload_ = deepcopy(payload)\n                payload_[\"media_ids\"] = media_ids\n\n                if no or not body:\n                    # strip text and replace it with the image representation\n                    payload_[\"status\"] = f\"{no + 1:02d}/{len(batches):02d}\"\n                payloads.append(payload_)\n\n        for no, payload in enumerate(payloads, start=1):\n            # Send Tweet\n            postokay, response = self._fetch(\n                self.twitter_tweet,\n                payload=payload,\n                json=False,\n            )\n\n            if not postokay:\n                # Track our error\n                has_error = True\n\n                errors = []\n                with contextlib.suppress(KeyError, TypeError):\n                    errors = [\n                        \"Error Code {}: {}\".format(\n                            e.get(\"code\", \"unk\"), e.get(\"message\")\n                        )\n                        for e in response[\"errors\"]\n                    ]\n\n                for error in errors:\n                    self.logger.debug(\n                        \"Tweet [%.2d/%.2d] Details: %s\",\n                        no,\n                        len(payloads),\n                        error,\n                    )\n                continue\n\n            try:\n                url = \"https://twitter.com/{}/status/{}\".format(\n                    response[\"user\"][\"screen_name\"], response[\"id_str\"]\n                )\n\n            except (KeyError, TypeError):\n                url = \"unknown\"\n\n            self.logger.debug(\n                \"Tweet [%.2d/%.2d] Details: %s\", no, len(payloads), url\n            )\n\n            self.logger.info(\n                \"Sent [%.2d/%.2d] Twitter notification as public tweet.\",\n                no,\n                len(payloads),\n            )\n\n        return not has_error\n\n    def _send_dm(\n        self,\n        body,\n        title=\"\",\n        notify_type=NotifyType.INFO,\n        attachments=None,\n        **kwargs,\n    ):\n        \"\"\"Twitter Direct Message.\"\"\"\n\n        # Error Tracking\n        has_error = False\n\n        payload = {\n            \"event\": {\n                \"type\": \"message_create\",\n                \"message_create\": {\n                    \"target\": {\n                        # This gets assigned\n                        \"recipient_id\": None,\n                    },\n                    \"message_data\": {\n                        \"text\": body,\n                    },\n                },\n            }\n        }\n\n        # Lookup our users (otherwise we look up ourselves)\n        targets = (\n            self._whoami(lazy=self.cache)\n            if not len(self.targets)\n            else self._user_lookup(self.targets, lazy=self.cache)\n        )\n\n        if not targets:\n            # We failed to lookup any users\n            self.logger.warning(\n                \"Failed to acquire user(s) to Direct Message via Twitter\"\n            )\n            return False\n\n        payloads = []\n        if not attachments:\n            payloads.append(payload)\n\n        else:\n            for no, attachment in enumerate(attachments):\n                payload_ = deepcopy(payload)\n                data = payload_[\"event\"][\"message_create\"][\"message_data\"]\n                data[\"attachment\"] = {\n                    \"type\": \"media\",\n                    \"media\": {\"id\": attachment[\"media_id\"]},\n                    \"additional_owners\": \",\".join(\n                        [str(x) for x in targets.values()]\n                    ),\n                }\n                if no or not body:\n                    # strip text and replace it with the image representation\n                    data[\"text\"] = f\"{no + 1:02d}/{len(attachments):02d}\"\n                payloads.append(payload_)\n\n        for no, payload in enumerate(payloads, start=1):\n            for screen_name, user_id in targets.items():\n                # Assign our user\n                target = payload[\"event\"][\"message_create\"][\"target\"]\n                target[\"recipient_id\"] = user_id\n\n                # Send Twitter DM\n                postokay, _response = self._fetch(\n                    self.twitter_dm,\n                    payload=payload,\n                )\n\n                if not postokay:\n                    # Track our error\n                    has_error = True\n                    continue\n\n                self.logger.info(\n                    f\"Sent [{no:02d}/{len(payloads):02d}] \"\n                    f\"Twitter DM notification to @{screen_name}.\"\n                )\n\n        return not has_error\n\n    def _whoami(self, lazy=True):\n        \"\"\"Looks details of current authenticated user.\"\"\"\n\n        if lazy and self._whoami_cache is not None:\n            # Use cached response\n            return self._whoami_cache\n\n        # Contains a mapping of screen_name to id\n        results = {}\n\n        # Send Twitter DM\n        postokay, response = self._fetch(\n            self.twitter_whoami,\n            method=\"GET\",\n            json=False,\n        )\n\n        if postokay:\n            try:\n                results[response[\"screen_name\"]] = response[\"id\"]\n                self._whoami_cache = {\n                    response[\"screen_name\"]: response[\"id\"],\n                }\n\n                self._user_cache.update(results)\n\n            except (TypeError, KeyError):\n                pass\n\n        return results\n\n    def _user_lookup(self, screen_name, lazy=True):\n        \"\"\"Looks up a screen name and returns the user id.\n\n        the screen_name can be a list/set/tuple as well\n        \"\"\"\n\n        # Contains a mapping of screen_name to id\n        results = {}\n\n        # Build a unique set of names\n        names = parse_list(screen_name)\n\n        if lazy and self._user_cache:\n            # Use cached response\n            results = {k: v for k, v in self._user_cache.items() if k in names}\n\n            # limit our names if they already exist in our cache\n            names = [name for name in names if name not in results]\n\n        if not len(names):\n            # They're is nothing further to do\n            return results\n\n        # Twitters API documents that it can lookup to 100\n        # results at a time.\n        # https://developer.twitter.com/en/docs/accounts-and-users/\\\n        #     follow-search-get-users/api-reference/get-users-lookup\n        for i in range(0, len(names), 100):\n            # Look up our names by their screen_name\n            postokay, response = self._fetch(\n                self.twitter_lookup,\n                payload={\n                    \"screen_name\": names[i : i + 100],\n                },\n                json=False,\n            )\n\n            if not postokay or not isinstance(response, list):\n                # Track our error\n                continue\n\n            # Update our user index\n            for entry in response:\n                with contextlib.suppress(TypeError, KeyError):\n                    results[entry[\"screen_name\"]] = entry[\"id\"]\n\n        # Cache our response for future use; this saves on un-nessisary extra\n        # hits against the Twitter API when we already know the answer\n        self._user_cache.update(results)\n\n        return results\n\n    def _fetch(self, url, payload=None, method=\"POST\", json=True):\n        \"\"\"Wrapper to Twitter API requests object.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n        }\n\n        data = None\n        files = None\n\n        # Open our attachment path if required:\n        if isinstance(payload, AttachBase):\n            # prepare payload\n            files = {\n                \"media\": (\n                    payload.name,\n                    # file handle is safely closed in `finally`; inline open is\n                    # intentional\n                    open(payload.path, \"rb\"),  # noqa: SIM115\n                ),\n            }\n\n        elif json:\n            headers[\"Content-Type\"] = \"application/json\"\n            data = dumps(payload)\n\n        else:\n            data = payload\n\n        auth = OAuth1(\n            self.ckey,\n            client_secret=self.csecret,\n            resource_owner_key=self.akey,\n            resource_owner_secret=self.asecret,\n        )\n\n        # Some Debug Logging\n        self.logger.debug(\n            f\"Twitter {method} URL: {url} \"\n            f\"(cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"Twitter Payload: {payload!s}\")\n\n        # By default set wait to None\n        wait = None\n\n        if self.ratelimit_remaining == 0:\n            # Determine how long we should wait for or if we should wait at\n            # all. This isn't fool-proof because we can't be sure the client\n            # time (calling this script) is completely synced up with the\n            # Twitter server.  One would hope we're on NTP and our clocks are\n            # the same allowing this to role smoothly:\n\n            now = datetime.now(timezone.utc).replace(tzinfo=None)\n            if now < self.ratelimit_reset:\n                # We need to throttle for the difference in seconds\n                # We add 0.5 seconds to the end just to allow a grace\n                # period.\n                wait = (self.ratelimit_reset - now).total_seconds() + 0.5\n\n        # Default content response object\n        content = {}\n\n        # Always call throttle before any remote server i/o is made;\n        self.throttle(wait=wait)\n\n        # acquire our request mode\n        fn = requests.post if method == \"POST\" else requests.get\n        try:\n            r = fn(\n                url,\n                data=data,\n                files=files,\n                headers=headers,\n                auth=auth,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                content = loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                content = {}\n\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyTwitter.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Twitter {} to {}: {}error={}.\".format(\n                        method, url, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                return (False, content)\n\n            try:\n                # Capture rate limiting if possible\n                self.ratelimit_remaining = int(\n                    r.headers.get(\"x-rate-limit-remaining\")\n                )\n                self.ratelimit_reset = datetime.fromtimestamp(\n                    int(r.headers.get(\"x-rate-limit-reset\")), timezone.utc\n                ).replace(tzinfo=None)\n\n            except (TypeError, ValueError):\n                # This is returned if we could not retrieve this information\n                # gracefully accept this state and move on\n                pass\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                f\"Exception received when sending Twitter {method} to {url}: \"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Mark our failure\n            return (False, content)\n\n        except OSError as e:\n            self.logger.warning(\n                \"An I/O error occurred while handling {}.\".format(\n                    payload.name\n                    if isinstance(payload, AttachBase)\n                    else payload\n                )\n            )\n            self.logger.debug(f\"I/O Exception: {e!s}\")\n            return (False, content)\n\n        finally:\n            # Close our file (if it's open) stored in the second element\n            # of our files tuple (index 1)\n            if files:\n                files[\"media\"][1].close()\n\n        return (True, content)\n\n    @property\n    def body_maxlen(self):\n        \"\"\"The maximum allowable characters allowed in the body per message\n        This is used during a Private DM Message Size (not Public Tweets which\n        are limited to 280 characters)\"\"\"\n        return 10000 if self.mode == TwitterMessageMode.DM else 280\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol[0],\n            self.ckey,\n            self.csecret,\n            self.akey,\n            self.asecret,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"mode\": self.mode,\n            \"batch\": \"yes\" if self.batch else \"no\",\n            \"cache\": \"yes\" if self.cache else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return (\n            \"{schema}://{ckey}/{csecret}/{akey}/{asecret}\"\n            \"/{targets}?{params}\".format(\n                schema=self.secure_protocol[0],\n                ckey=self.pprint(self.ckey, privacy, safe=\"\"),\n                csecret=self.pprint(\n                    self.csecret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                akey=self.pprint(self.akey, privacy, safe=\"\"),\n                asecret=self.pprint(\n                    self.asecret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n                targets=(\n                    \"/\".join([\n                        NotifyTwitter.quote(f\"@{target}\", safe=\"@\")\n                        for target in self.targets\n                    ])\n                    if self.targets\n                    else \"\"\n                ),\n                params=NotifyTwitter.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Acquire remaining tokens\n        tokens = NotifyTwitter.split_path(results[\"fullpath\"])\n\n        # The consumer token is stored in the hostname\n        results[\"ckey\"] = NotifyTwitter.unquote(results[\"host\"])\n\n        #\n        # Now fetch the remaining tokens\n        #\n\n        # Consumer Secret\n        results[\"csecret\"] = tokens.pop(0) if tokens else None\n        # Access Token Key\n        results[\"akey\"] = tokens.pop(0) if tokens else None\n        # Access Token Secret\n        results[\"asecret\"] = tokens.pop(0) if tokens else None\n\n        # The defined twitter mode\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            results[\"mode\"] = NotifyTwitter.unquote(results[\"qsd\"][\"mode\"])\n\n        elif results[\"schema\"].startswith(\"tweet\"):\n            results[\"mode\"] = TwitterMessageMode.TWEET\n\n        results[\"targets\"] = []\n\n        # if a user has been defined, add it to the list of targets\n        if results.get(\"user\"):\n            results[\"targets\"].append(results.get(\"user\"))\n\n        # Store any remaining items as potential targets\n        results[\"targets\"].extend(tokens)\n\n        # Get Cache Flag (reduces lookup hits)\n        if \"cache\" in results[\"qsd\"] and len(results[\"qsd\"][\"cache\"]):\n            results[\"cache\"] = parse_bool(results[\"qsd\"][\"cache\"], True)\n\n        # Get Batch Mode Flag\n        results[\"batch\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"batch\", NotifyTwitter.template_args[\"batch\"][\"default\"]\n            )\n        )\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyTwitter.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/vapid/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nfrom itertools import chain\nfrom json import dumps\nimport os\nimport time\n\nimport requests\n\nfrom ...common import NotifyImageSize, NotifyType, PersistentStoreMode\nfrom ...locale import gettext_lazy as _\nfrom ...utils import pem as _pem\nfrom ...utils.base64 import base64_urlencode\nfrom ...utils.parse import is_email, parse_bool, parse_list\nfrom ..base import NotifyBase\nfrom . import subscription\n\n\nclass VapidPushMode:\n    \"\"\"Supported Vapid Push Services.\"\"\"\n\n    CHROME = \"chrome\"\n    FIREFOX = \"firefox\"\n    EDGE = \"edge\"\n    OPERA = \"opera\"\n    APPLE = \"apple\"\n    SAMSUNG = \"samsung\"\n    BRAVE = \"brave\"\n    GENERIC = \"generic\"\n\n\nVAPID_API_LOOKUP = {\n    VapidPushMode.CHROME: \"https://fcm.googleapis.com/fcm/send\",\n    VapidPushMode.FIREFOX: (\n        \"https://updates.push.services.mozilla.com/wpush/v1\"\n    ),\n    VapidPushMode.EDGE: (\n        \"https://fcm.googleapis.com/fcm/send\"\n    ),  # Edge uses FCM too\n    VapidPushMode.OPERA: (\n        \"https://fcm.googleapis.com/fcm/send\"\n    ),  # Opera is Chromium-based\n    VapidPushMode.APPLE: (\n        \"https://web.push.apple.com\"\n    ),  # Apple Web Push base endpoint\n    VapidPushMode.BRAVE: \"https://fcm.googleapis.com/fcm/send\",\n    VapidPushMode.SAMSUNG: \"https://fcm.googleapis.com/fcm/send\",\n    VapidPushMode.GENERIC: \"https://fcm.googleapis.com/fcm/send\",\n}\n\nVAPID_PUSH_MODES = (\n    VapidPushMode.CHROME,\n    VapidPushMode.FIREFOX,\n    VapidPushMode.EDGE,\n    VapidPushMode.OPERA,\n    VapidPushMode.APPLE,\n)\n\n\nclass NotifyVapid(NotifyBase):\n    \"\"\"A wrapper for WebPush/Vapid notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = subscription.CRYPTOGRAPHY_SUPPORT and _pem.PEM_SUPPORT\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"packages_required\": \"cryptography\"\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Vapid Web Push Notifications\"\n\n    # The services URL\n    service_url = (\n        \"https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid\"\n    )\n\n    # The default protocol\n    secure_protocol = \"vapid\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/vapid/\"\n\n    # There is no reason we should exceed 5KB when reading in a PEM file.\n    # If it is more than this, then it is not accepted.\n    max_vapid_keyfile_size = 5000\n\n    # There is no reason we should exceed 5MB when reading in a JSON file.\n    # If it is more than this, then it is not accepted.\n    max_vapid_subfile_size = 5242880\n\n    # The maximum length of the messge can be 4096\n    # just choosing a safe number below this to allow for padding and\n    # encryption\n    body_maxlen = 4000\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Our default is to no not use persistent storage beyond in-memory\n    # reference; this allows us to auto-generate our config if needed\n    storage_mode = PersistentStoreMode.AUTO\n\n    # 43200 = 12 hours\n    vapid_jwt_expiration_sec = 43200\n\n    # Subscription file\n    vapid_subscription_file = \"subscriptions.json\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_72\n\n    # Define object templates\n    templates = (\n        \"{schema}://{subscriber}\",\n        \"{schema}://{subscriber}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"subscriber\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template args\n    template_args = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"mode\": {\n                \"name\": _(\"Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": VAPID_PUSH_MODES,\n                \"default\": VAPID_PUSH_MODES[0],\n                \"map_to\": \"mode\",\n            },\n            # Default Time To Live (defined in seconds)\n            # 0 (Zero) - message will be delivered only if the device is\n            # reacheable\n            \"ttl\": {\n                \"name\": _(\"ttl\"),\n                \"type\": \"int\",\n                \"default\": 0,\n                \"min\": 0,\n                \"max\": 60,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"from\": {\n                \"alias_of\": \"subscriber\",\n            },\n            \"keyfile\": {\n                # A Private Keyfile is required to sign header\n                \"name\": _(\"PEM Private KeyFile\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"subfile\": {\n                # A Subscripion File is required to sign header\n                \"name\": _(\"Subscripion File\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    def __init__(\n        self,\n        subscriber,\n        mode=None,\n        targets=None,\n        keyfile=None,\n        subfile=None,\n        include_image=None,\n        ttl=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Vapid Messaging.\"\"\"\n        super().__init__(**kwargs)\n\n        # Path to our Private Key file\n        self.keyfile = None\n\n        # Path to our subscription.json file\n        self.subfile = None\n\n        #\n        # Our Targets\n        #\n        self.targets = []\n        self._invalid_targets = []\n\n        # default subscriptions\n        self.subscriptions = {}\n        self.subscriptions_loaded = False\n        self.private_key_loaded = False\n\n        # Set our Time to Live Flag\n        self.ttl = self.template_args[\"ttl\"][\"default\"]\n        if ttl is not None:\n            with contextlib.suppress(ValueError, TypeError):\n                # Store our TTL (Time To live) if it is a valid integer\n                self.ttl = int(ttl)\n\n            if (\n                self.ttl < self.template_args[\"ttl\"][\"min\"]\n                or self.ttl > self.template_args[\"ttl\"][\"max\"]\n            ):\n                msg = f\"The Vapid TTL specified ({self.ttl}) is out of range.\"\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        # Place a thumbnail image inline with the message body\n        self.include_image = (\n            self.template_args[\"image\"][\"default\"]\n            if include_image is None\n            else include_image\n        )\n\n        result = is_email(subscriber)\n        if not result:\n            msg = f\"An invalid Vapid Subscriber({subscriber}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n        self.subscriber = result[\"full_email\"]\n\n        # Store our Mode/service\n        try:\n            self.mode = (\n                NotifyVapid.template_args[\"mode\"][\"default\"]\n                if mode is None\n                else mode.lower()\n            )\n\n            if self.mode not in VAPID_PUSH_MODES:\n                # allow the outer except to handle this common response\n                raise IndexError()\n\n        except (AttributeError, IndexError, TypeError):\n            # Invalid region specified\n            msg = f\"The Vapid mode specified ({mode}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        # Our Private keyfile\n        self.keyfile = keyfile\n\n        # Our Subscription file\n        self.subfile = subfile\n\n        # Prepare our PEM Object\n        self.pem = _pem.ApprisePEMController(self.store.path, asset=self.asset)\n\n        # Create our subscription object\n        self.subscriptions = subscription.WebPushSubscriptionManager(\n            asset=self.asset\n        )\n\n        if (\n            self.subfile is None\n            and self.store.mode != PersistentStoreMode.MEMORY\n            and self.asset.pem_autogen\n        ):\n\n            self.subfile = os.path.join(\n                self.store.path, self.vapid_subscription_file\n            )\n            if not os.path.exists(self.subfile) and self.subscriptions.write(\n                self.subfile\n            ):\n                self.logger.info(\n                    \"Vapid auto-generated %s/%s\",\n                    os.path.basename(self.store.path),\n                    self.vapid_subscription_file,\n                )\n\n        # Acquire our targets for parsing\n        self.targets = parse_list(targets)\n        if not self.targets:\n            # Add ourselves\n            self.targets.append(self.subscriber)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Vapid Notification.\"\"\"\n        if not self.private_key_loaded and (\n            (\n                self.keyfile\n                and not self.pem.private_key(autogen=False, autodetect=False)\n                and not self.pem.load_private_key(self.keyfile)\n            )\n            or (not self.keyfile and not self.pem)\n        ):\n            self.logger.warning(\n                \"Provided Vapid/WebPush (PEM) Private Key file could \"\n                \"not be loaded.\"\n            )\n            self.private_key_loaded = True\n            return False\n        else:\n            self.private_key_loaded = True\n\n        if not self.targets:\n            # There is no one to notify; we're done\n            self.logger.warning(\"There are no Vapid targets to notify\")\n            return False\n\n        if not self.subscriptions_loaded and self.subfile:\n            # Toggle our loaded flag to prevent trying again later\n            self.subscriptions_loaded = True\n            if not self.subscriptions.load(\n                self.subfile, byte_limit=self.max_vapid_subfile_size\n            ):\n                self.logger.warning(\n                    \"Provided Vapid/WebPush subscriptions file could not be \"\n                    \"loaded.\"\n                )\n                return False\n\n        if not self.subscriptions:\n            self.logger.warning(\"Vapid could not load subscriptions\")\n            return False\n\n        if not self.pem.private_key(autogen=False, autodetect=False):\n            self.logger.warning(\n                \"No Vapid/WebPush (PEM) Private Key file could be loaded.\"\n            )\n            return False\n\n        # Prepare our notify URL (based on our mode)\n        notify_url = VAPID_API_LOOKUP[self.mode]\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"TTL\": str(self.ttl),\n            \"Content-Encoding\": \"aes128gcm\",\n            \"Content-Type\": \"application/octet-stream\",\n            \"Authorization\": f\"vapid t={self.jwt_token}, k={self.public_key}\",\n        }\n\n        has_error = False\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n        while len(targets):\n            target = targets.pop(0)\n            if target not in self.subscriptions:\n                self.logger.warning(\n                    \"Dropped Vapid user \"\n                    f\"({target}) specified - not found in subscriptions.json.\",\n                )\n                # Save ourselves from doing this again\n                self._invalid_targets.append(target)\n                self.targets.remove(target)\n                has_error = True\n                continue\n\n            # Encrypt our payload\n            encrypted_payload = self.pem.encrypt_webpush(\n                body,\n                public_key=self.subscriptions[target].public_key,\n                auth_secret=self.subscriptions[target].auth_secret,\n            )\n\n            self.logger.debug(\n                \"Vapid %s POST URL: %s (cert_verify=%r)\",\n                self.mode,\n                notify_url,\n                self.verify_certificate,\n            )\n            self.logger.debug(\n                \"Vapid %s Encrypted Payload: %d byte(s)\", self.mode, len(body)\n            )\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    notify_url,\n                    data=encrypted_payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code not in (\n                    requests.codes.ok,\n                    requests.codes.no_content,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send {} Vapid notification: \"\n                        \"{}{}error={}.\".format(\n                            self.mode,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    has_error = True\n\n                else:\n                    self.logger.info(\"Sent %s Vapid notification.\", self.mode)\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Vapid notification.\"\n                )\n                self.logger.debug(\"Socket Exception: %s\", e)\n\n                has_error = True\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.mode, self.subscriber)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"mode\": self.mode,\n            \"ttl\": str(self.ttl),\n        }\n\n        if self.keyfile:\n            # Include our keyfile if specified\n            params[\"keyfile\"] = self.keyfile\n\n        if self.subfile:\n            # Include our subfile if specified\n            params[\"subfile\"] = self.subfile\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        targets = (\n            self.targets\n            if not (\n                self.targets == 1\n                and self.targets[0].lower() == self.subscriber.lower()\n            )\n            else []\n        )\n        return \"{schema}://{subscriber}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            subscriber=NotifyVapid.quote(self.subscriber, safe=\"@\"),\n            targets=\"/\".join(\n                chain(\n                    [str(t) for t in targets],\n                    [\n                        NotifyVapid.quote(x, safe=\"@\")\n                        for x in self._invalid_targets\n                    ],\n                )\n            ),\n            params=NotifyVapid.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Prepare our targets\n        results[\"targets\"] = []\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"subscriber\"] = NotifyVapid.unquote(results[\"qsd\"][\"from\"])\n\n            if results[\"user\"] and results[\"host\"]:\n                # whatever is left on the URL goes\n                results[\"targets\"].append(\n                    \"{}@{}\".format(\n                        NotifyVapid.unquote(results[\"user\"]),\n                        NotifyVapid.unquote(results[\"host\"]),\n                    )\n                )\n\n            elif results[\"host\"]:\n                results[\"targets\"].append(NotifyVapid.unquote(results[\"host\"]))\n\n        else:\n            # Acquire our subscriber information\n            results[\"subscriber\"] = \"{}@{}\".format(\n                NotifyVapid.unquote(results[\"user\"]),\n                NotifyVapid.unquote(results[\"host\"]),\n            )\n\n        results[\"targets\"].extend(NotifyVapid.split_path(results[\"fullpath\"]))\n\n        # Get our mode\n        results[\"mode\"] = results[\"qsd\"].get(\"mode\")\n\n        # Get Image Flag\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyVapid.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyVapid.parse_list(results[\"qsd\"][\"to\"])\n\n        # Our Private Keyfile (PEM)\n        if \"keyfile\" in results[\"qsd\"] and results[\"qsd\"][\"keyfile\"]:\n            results[\"keyfile\"] = NotifyVapid.unquote(results[\"qsd\"][\"keyfile\"])\n\n        # Our Subscription File (JSON)\n        if \"subfile\" in results[\"qsd\"] and results[\"qsd\"][\"subfile\"]:\n            results[\"subfile\"] = NotifyVapid.unquote(results[\"qsd\"][\"subfile\"])\n\n        # Support the 'ttl' variable\n        if \"ttl\" in results[\"qsd\"] and len(results[\"qsd\"][\"ttl\"]):\n            results[\"ttl\"] = NotifyVapid.unquote(results[\"qsd\"][\"ttl\"])\n\n        return results\n\n    @property\n    def jwt_token(self):\n        \"\"\"Returns our VAPID Token based on class details.\"\"\"\n        # JWT header\n        header = {\"alg\": \"ES256\", \"typ\": \"JWT\"}\n\n        # JWT payload\n        payload = {\n            \"aud\": VAPID_API_LOOKUP[self.mode],\n            \"exp\": int(time.time()) + self.vapid_jwt_expiration_sec,\n            \"sub\": f\"mailto:{self.subscriber}\",\n        }\n\n        # Base64 URL encode header and payload\n        header_b64 = base64_urlencode(\n            dumps(header, separators=(\",\", \":\")).encode(\"utf-8\")\n        )\n        payload_b64 = base64_urlencode(\n            dumps(payload, separators=(\",\", \":\")).encode(\"utf-8\")\n        )\n        signing_input = f\"{header_b64}.{payload_b64}\".encode()\n        signature_b64 = base64_urlencode(self.pem.sign(signing_input))\n\n        # Return final token\n        return f\"{header_b64}.{payload_b64}.{signature_b64}\"\n\n    @property\n    def public_key(self):\n        \"\"\"Returns our public key representation.\"\"\"\n        return self.pem.x962_str\n"
  },
  {
    "path": "apprise/plugins/vapid/subscription.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\nfrom typing import Optional, Union\n\nfrom ...apprise_attachment import AppriseAttachment\nfrom ...asset import AppriseAsset\nfrom ...exception import AppriseInvalidData\nfrom ...utils.base64 import base64_urldecode\n\ntry:\n    from cryptography.hazmat.primitives.asymmetric import ec\n\n    # Cryptography Support enabled\n    CRYPTOGRAPHY_SUPPORT = True\n\nexcept ImportError:\n    # Cryptography Support disabled\n    CRYPTOGRAPHY_SUPPORT = False\n\n\nclass WebPushSubscription:\n    \"\"\"WebPush Subscription.\"\"\"\n\n    # Format:\n    # {\n    #     \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123...\",\n    #     \"keys\": {\n    #         \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...\",\n    #         \"auth\": \"k9Xzm43nBGo=\",\n    #     }\n    # }\n    def __init__(self, content: Union[str, dict, None] = None) -> None:\n        \"\"\"Prepares a webpush object provided with content Content can be a\n        dictionary, or JSON String.\"\"\"\n\n        # Our variables\n        self.__endpoint = None\n        self.__p256dh = None\n        self.__auth = None\n        self.__auth_secret = None\n        self.__public_key = None\n\n        if content is not None and not self.load(content):\n            raise AppriseInvalidData(\"Could not load subscription\")\n\n    def load(self, content: Union[str, dict, None] = None) -> bool:\n        \"\"\"Performs the loading/validation of the object.\"\"\"\n\n        # Reset our variables\n        self.__endpoint = None\n        self.__p256dh = None\n        self.__auth = None\n        self.__auth_secret = None\n        self.__public_key = None\n\n        if not CRYPTOGRAPHY_SUPPORT:\n            return False\n\n        if isinstance(content, str):\n            try:\n                content = json.loads(content)\n\n            except (json.decoder.JSONDecodeError, TypeError, OSError):\n                # Bad data\n                return False\n\n        if not isinstance(content, dict):\n            # We could not load he result set\n            return False\n\n        # Retreive our contents for validation\n        endpoint = content.get(\"endpoint\")\n        if not isinstance(endpoint, str):\n            return False\n\n        try:\n            p256dh = base64_urldecode(content[\"keys\"][\"p256dh\"])\n            if not p256dh:\n                return False\n\n            auth_secret = base64_urldecode(content[\"keys\"][\"auth\"])\n            if not auth_secret:\n                return False\n\n        except KeyError:\n            return False\n\n        try:\n            # Store our data\n            self.__public_key = ec.EllipticCurvePublicKey.from_encoded_point(\n                ec.SECP256R1(),\n                p256dh,\n            )\n\n        except ValueError:\n            # Invalid p256dh key (Can't load Public Key)\n            return False\n\n        self.__endpoint = endpoint\n        self.__p256dh = content[\"keys\"][\"p256dh\"]\n        self.__auth = content[\"keys\"][\"auth\"]\n        self.__auth_secret = auth_secret\n\n        return True\n\n    def write(self, path: str, indent: int = 2) -> bool:\n        \"\"\"Writes content to disk based on path specified.\n\n        Content is a JSON file, so ideally you may wish to have `.json' as it's\n        extension for clarity\n        \"\"\"\n        if not self.__public_key:\n            return False\n\n        try:\n            with open(path, \"w\", encoding=\"utf-8\") as f:\n                json.dump(self.dict, f, indent=indent)\n\n        except (TypeError, OSError):\n            # Could not write content\n            return False\n\n        return True\n\n    @property\n    def auth(self) -> Optional[str]:\n        return self.__auth if self.__public_key else None\n\n    @property\n    def endpoint(self) -> Optional[str]:\n        return self.__endpoint if self.__public_key else None\n\n    @property\n    def p256dh(self) -> Optional[str]:\n        return self.__p256dh if self.__public_key else None\n\n    @property\n    def auth_secret(self) -> Optional[bytes]:\n        return self.__auth_secret if self.__public_key else None\n\n    @property\n    def public_key(self) -> Optional[\"ec.EllipticCurvePublicKey\"]:\n        return self.__public_key\n\n    @property\n    def dict(self) -> dict:\n        return (\n            {\n                \"endpoint\": self.__endpoint,\n                \"keys\": {\n                    \"p256dh\": self.__p256dh,\n                    \"auth\": self.__auth,\n                },\n            }\n            if self.__public_key\n            else {\n                \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123...\",\n                \"keys\": {\n                    \"p256dh\": \"<place public key in base64 here>\",\n                    \"auth\": \"<place auth in base64 here>\",\n                },\n            }\n        )\n\n    def json(self, indent: int = 2) -> str:\n        \"\"\"Returns JSON representation of the object.\"\"\"\n        return json.dumps(self.dict, indent=indent)\n\n    def __bool__(self) -> bool:\n        \"\"\"Handle 'if' statement.\"\"\"\n        return bool(self.__public_key)\n\n    def __str__(self) -> str:\n        \"\"\"Returns our JSON entry as a string.\"\"\"\n        # Return the first 16 characters of the detected endpoint subscription\n        # id\n        return (\n            \"\" if not self.__endpoint else self.__endpoint.split(\"/\")[-1][:16]\n        )\n\n\nclass WebPushSubscriptionManager:\n    \"\"\"WebPush Subscription Manager.\"\"\"\n\n    # Format:\n    # {\n    #     \"name1\": {\n    #         \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123...\",\n    #         \"keys\": {\n    #             \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...\",\n    #             \"auth\": \"k9Xzm43nBGo=\",\n    #         }\n    #     },\n    #     \"name2\": {\n    #         \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123...\",\n    #         \"keys\": {\n    #             \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...\",\n    #             \"auth\": \"k9Xzm43nBGo=\",\n    #         }\n    #     },\n\n    # Defines the number of failures we can accept before we abort and assume\n    # the file is bad\n    max_load_failure_count = 3\n\n    def __init__(self, asset: Optional[\"AppriseAsset\"] = None) -> None:\n        \"\"\"Webpush Subscription Manager.\"\"\"\n\n        # Our subscriptions\n        self.__subscriptions = {}\n\n        # Prepare our Asset Object\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n    def __getitem__(self, key: str) -> WebPushSubscription:\n        \"\"\"Returns our indexed value if it exists.\"\"\"\n        return self.__subscriptions[key.lower()]\n\n    def __setitem__(\n        self, name: str, subscription: Union[WebPushSubscription, str, dict]\n    ) -> None:\n        \"\"\"Set's our object if possible.\"\"\"\n\n        if not self.add(subscription, name=name.lower()):\n            raise AppriseInvalidData(\"Invalid subscription provided\")\n\n    def add(\n        self,\n        subscription: Union[WebPushSubscription, str, dict],\n        name: Optional[str] = None,\n    ) -> bool:\n        \"\"\"Add a subscription into our manager.\"\"\"\n\n        if not isinstance(subscription, WebPushSubscription):\n            try:\n                # Support loading our object\n                subscription = WebPushSubscription(subscription)\n\n            except AppriseInvalidData:\n                return False\n\n        if name is None:\n            name = str(subscription)\n\n        self.__subscriptions[name.lower()] = subscription\n        return True\n\n    def __bool__(self) -> bool:\n        \"\"\"True is returned if at least one subscription has been loaded.\"\"\"\n        return bool(self.__subscriptions)\n\n    def __len__(self) -> int:\n        \"\"\"Returns the number of servers loaded; this includes those found\n        within loaded configuration.\n\n        This funtion nnever actually counts the Config entry themselves (if\n        they exist), only what they contain.\n        \"\"\"\n        return len(self.__subscriptions)\n\n    def __iadd__(\n        self, subscription: Union[WebPushSubscription, str, dict]\n    ) -> \"WebPushSubscriptionManager\":\n\n        if not self.add(subscription):\n            raise AppriseInvalidData(\"Invalid subscription provided\")\n\n        return self\n\n    def __contains__(self, key: str) -> bool:\n        \"\"\"Checks if the key exists.\"\"\"\n        return key.lower() in self.__subscriptions\n\n    def clear(self) -> None:\n        \"\"\"Empties our server list.\"\"\"\n        self.__subscriptions.clear()\n\n    @property\n    def dict(self) -> dict:\n        \"\"\"Returns a dictionary of all entries.\"\"\"\n        return (\n            {k: v.dict for k, v in self.__subscriptions.items()}\n            if self.__subscriptions\n            else {}\n        )\n\n    def load(self, path: str, byte_limit=0) -> bool:\n        \"\"\"Writes content to disk based on path specified.  Content is a JSON\n        file, so ideally you may wish to have `.json' as it's extension for\n        clarity.\n\n        if byte_limit is zero, then we do not limit our file size, otherwise\n        set this to the bytes you want to restrict yourself by\n        \"\"\"\n\n        # Reset our object\n        self.clear()\n\n        # Create our attachment object\n        attach = AppriseAttachment(asset=self.asset)\n\n        # Add our path\n        attach.add(path)\n\n        if byte_limit > 0:\n            # Enforce maximum file size\n            attach[0].max_file_size = byte_limit\n\n        if not attach.sync():\n            return False\n\n        try:\n            # Otherwise open our path\n            with open(attach[0].path, encoding=\"utf-8\") as f:\n                content = json.load(f)\n\n        except (json.decoder.JSONDecodeError, TypeError, OSError):\n            # Could not read\n            return False\n\n        if not isinstance(content, dict):\n            # Not a list of dictionaries\n            return False\n\n        # Verify if we're dealing with a single element:\n        # {\n        #     \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123...\",\n        #     \"keys\": {\n        #         \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...\",\n        #         \"auth\": \"k9Xzm43nBGo=\",\n        #     }\n        # }\n        #\n        # or if we're dealing with a multiple set\n        #\n        # {\n        #     \"name1\": {\n        #         \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123...\",\n        #         \"keys\": {\n        #             \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...\",\n        #             \"auth\": \"k9Xzm43nBGo=\",\n        #         }\n        #     },\n        #     \"name2\": {\n        #         \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123...\",\n        #         \"keys\": {\n        #             \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR...\",\n        #             \"auth\": \"k9Xzm43nBGo=\",\n        #         }\n        #     },\n\n        error_count = 0\n        if \"endpoint\" in content and \"keys\" in content:\n            if not self.add(content):\n                return False\n\n        else:\n            for name, subscription in content.items():\n                if not self.add(subscription, name=name.lower()):\n                    error_count += 1\n                    if error_count > self.max_load_failure_count:\n                        self.clear()\n                        return False\n\n        return True\n\n    def write(self, path: str, indent: int = 2) -> bool:\n        \"\"\"Writes content to disk based on path specified.\n\n        Content is a JSON file, so ideally you may wish to have `.json' as it's\n        extension for clarity\n        \"\"\"\n        try:\n            with open(path, \"w\", encoding=\"utf-8\") as f:\n                json.dump(self.dict, f, indent=indent)\n\n        except (TypeError, OSError):\n            # Could not write content\n            return False\n\n        return True\n\n    def json(self, indent: int = 2) -> str:\n        \"\"\"Returns JSON representation of the object.\"\"\"\n        return json.dumps(self.dict, indent=indent)\n"
  },
  {
    "path": "apprise/plugins/viber.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# API Reference: https://creators.viber.com/docs/bots-api/\\\n#       resources/messaging/send-message\nfrom __future__ import annotations\n\nfrom collections.abc import Iterable\nfrom json import dumps, loads\nfrom typing import Any, Optional\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyViber(NotifyBase):\n    \"\"\"Send a Viber Bot message using the Viber REST Bot API.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = _(\"Viber\")\n\n    # The Services URL\n    service_url = \"https://www.viber.com/\"\n\n    # The default protocol\n    secure_protocol = \"viber\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/viber/\"\n\n    # Viber notification endpoint\n    notify_url = \"https://chatapi.viber.com/pa/send_message\"\n\n    # Service limits (documented maximum is 30KB)\n    # Note: this is not exact byte accounting (UTF-8 vs chars), but it keeps\n    # messages in the expected range.\n    body_maxlen = 30000\n\n    # We don't support titles for Viber notifications\n    title_maxlen = 0\n\n    # Maximum characters allowed in sender name\n    viber_sender_name_limit = 28\n\n    # Minimal URL; endpoint is fixed, token is the first path entry.\n    templates = (\n        \"{schema}://{token}/{targets}\",\n    )\n\n    template_tokens = dict(NotifyBase.template_tokens, **{\n        \"token\": {\n            \"name\": _(\"Authentication Token\"),\n            \"type\": \"string\",\n            \"private\": True,\n            \"required\": True,\n        },\n        \"targets\": {\n            \"name\": _(\"Receiver IDs\"),\n            \"type\": \"list:string\",\n            \"required\": True,\n        },\n    })\n\n    template_args = dict(NotifyBase.template_args, **{\n        # Viber requires sender.name\n        \"from\": {\n            \"name\": _(\"Bot Name\"),\n            \"type\": \"string\",\n            \"map_to\": \"source\",\n        },\n        # Optional sender.avatar URL\n        \"avatar\": {\n            \"name\": _(\"Bot Avatar URL\"),\n            \"type\": \"string\",\n        },\n        \"token\": {\n            \"alias_of\": \"token\",\n        },\n        # Allow targets to also come from query string\n        \"to\": {\n            \"alias_of\": \"targets\"\n        }\n    })\n\n    def __init__(\n        self,\n        token: str,\n        targets: Optional[Iterable[str]] = None,\n        source: Optional[str] = None,\n        avatar: Optional[str] = None,\n        **kwargs: Any,\n    ) -> None:\n        super().__init__(**kwargs)\n\n        self.token = validate_regex(token)\n        if not self.token:\n            msg = \"An invalid Viber authentication token was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Sender name is required by the API; provide a safe default\n        sourcev = (source or \"\").strip()\n        if len(sourcev) > self.viber_sender_name_limit:\n            self.logger.warning(\n                f\"Viber sender name exceeds {self.viber_sender_name_limit} \"\n                \"characters, truncating.\")\n            sourcev = sourcev[:self.viber_sender_name_limit]\n        self.source: str = sourcev\n\n        self.avatar: Optional[str] = (avatar or \"\").strip() or None\n\n        # Store our targets\n        self.targets = parse_list(targets)\n\n    def __len__(self) -> int:\n        \"\"\"Number of outbound HTTP requests this configuration will perform.\"\"\"\n        return max(1, len(self.targets))\n\n    def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:\n        \"\"\"Rebuild the Apprise URL with secrets redacted.\"\"\"\n\n        # Define any URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        if self.source:\n            params[\"from\"] = self.source\n\n        if self.avatar:\n            params[\"avatar\"] = self.avatar\n\n        # Path targets\n        tgt = \"\"\n        if self.targets:\n            tgt = \"/\".join(self.quote(t, safe=\"\") for t in self.targets)\n\n        # Token in first path element\n        token = self.pprint(\n            self.token, privacy, mode=PrivacyMode.Secret, safe=\"\")\n\n        query = self.urlencode(params)\n        return (\n            f\"{self.secure_protocol}://{token}/\"\n            + tgt + (f\"?{query}\" if query else \"\"))\n\n    @property\n    def url_identifier(self) -> str:\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Send a Viber notification to each configured receiver ID.\"\"\"\n        if not self.targets:\n            # There were no services to notify\n            self.logger.warning(\"There were no Viber targets to notify\")\n            return False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"X-Viber-Auth-Token\": self.token,\n        }\n\n        # Prepare our payload\n        payload: dict[str, Any] = {\n            \"type\": \"text\",\n            \"text\": body,\n            \"sender\": {\n                \"name\": self.source if self.source\n                else self.app_desc[:self.viber_sender_name_limit]},\n        }\n\n        if self.avatar:\n            payload[\"sender\"][\"avatar\"] = self.avatar\n\n        content = None\n        status_str = None\n        has_error = False\n\n        for dest in self.targets:\n            payload[\"receiver\"] = dest\n\n            self.throttle()\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                # Viber returns the following on success:\n                #   {\"status\":0,\"status_message\":\"ok\",...}\n                try:\n                    content = loads(r.content)\n\n                except (AttributeError, TypeError, ValueError, KeyError):\n                    # ValueError = r.content is Unparsable\n                    # TypeError = r.content is None\n                    # AttributeError = r is None\n                    # KeyError = 'result' is not found in result\n                    content = {}\n                    self.logger.warning(\n                        \"Invalid JSON response from Viber sending \"\n                        f\"notification to {dest}\")\n                    self.logger.debug(\"Response Details:\\n%s\", r.content)\n                    has_error = True\n                    continue\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = (\n                        content.get(\"status_message\")\n                        if content.get(\"status_message\")\n                        else self.http_response_code_lookup(\n                            r.status_code\n                        )\n                    )\n                    self.logger.warning(\n                        f\"Failed to send Viber notification to {dest} - \"\n                        f\"{status_str} error={r.status_code}.\"\n                    )\n\n                    self.logger.debug(\"Response Details:\\n%s\", r.content)\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                if int(content.get(\"status\", -1)) != 0:\n                    self.logger.warning(\n                        f\"Failed to send Viber notification to {dest} - \"\n                        \"Viber Error {%s} (status=%s)\",\n                        content.get(\"status_message\", \"unknown\"),\n                        content.get(\"status\", \"unknown\"),\n                    )\n                    self.logger.debug(\"Response Details:\\n%s\", r.content)\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occured sending Viber notification \"\n                    \"to %s\",\n                    dest,\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @staticmethod\n    def parse_url(url: str) -> dict[str, Any]:\n        \"\"\"Parse the URL and return arguments to instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Prepare a Full path to work with\n        results[\"targets\"] = [\n            NotifyViber.unquote(results[\"host\"]),\n            *NotifyViber.split_path(results[\"fullpath\"])]\n\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = NotifyViber.unquote(\n                results[\"qsd\"][\"token\"]\n            )\n\n        else:\n            results[\"token\"] = results[\"targets\"][0]\n            results[\"targets\"] = results[\"targets\"][1:]\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += results[\"qsd\"][\"to\"]\n\n        # Map 'from' -> source\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyViber.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n\n        # Map avatar\n        if \"avatar\" in results[\"qsd\"] and len(results[\"qsd\"][\"avatar\"]):\n            results[\"avatar\"] = NotifyViber.unquote(\n                results[\"qsd\"][\"avatar\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/voipms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Create an account https://voip.ms/ if you don't already have one\n#\n# Enable API and set an API password here:\n#   - https://voip.ms/m/api.php\n#\n# Read more about VoIP.ms API here:\n#   - https://voip.ms/m/apidocs.php\n\nimport contextlib\nfrom json import loads\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, is_phone_no, parse_phone_no\nfrom .base import NotifyBase\n\n\nclass NotifyVoipms(NotifyBase):\n    \"\"\"A wrapper for VoIPms Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"VoIPms\"\n\n    # The services URL\n    service_url = \"https://voip.ms\"\n\n    # The default protocol\n    secure_protocol = \"voipms\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/voipms/\"\n\n    # VoIPms uses the http protocol with JSON requests\n    notify_url = \"https://voip.ms/api/v1/rest.php\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # The supported country code by VoIP.ms\n    voip_ms_country_code = \"1\"\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\"{schema}://{password}:{email}/{from_phone}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"email\": {\n                \"name\": _(\"User Email\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"from\": {\n                \"alias_of\": \"from_phone\",\n            },\n        },\n    )\n\n    def __init__(self, email, source=None, targets=None, **kwargs):\n        \"\"\"Initialize VoIPms Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Validate our params here.\n        if self.password is None:\n            msg = \"Password has to be specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # User is the email associated with the account\n        result = is_email(email)\n        if not result:\n            msg = f\"An invalid VoIPms user email: ({email}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n        self.email = result[\"full_email\"]\n\n        # Validate our source Phone #\n        result = is_phone_no(source)\n        if not result:\n            msg = f\"An invalid VoIPms source phone # ({source}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Source Phone # only supports +1 country code\n        # Allow 7 digit phones (presume they're local with +1 country code)\n        if (\n            result[\"country\"]\n            and result[\"country\"] != self.voip_ms_country_code\n        ):\n            msg = (\n                \"VoIPms only supports +1 country code \"\n                f\"({source}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our source phone number (without country code)\n        self.source = result[\"area\"] + result[\"line\"]\n\n        # Parse our targets\n        self.targets = []\n\n        if targets:\n            for target in parse_phone_no(targets):\n                # Validate targets and drop bad ones:\n                result = is_phone_no(target)\n\n                # Target Phone # only supports +1 country code\n                if (\n                    result[\"country\"]\n                    and result[\"country\"] != self.voip_ms_country_code\n                ):\n                    self.logger.warning(\n                        f\"Ignoring invalid phone # ({target}) specified.\",\n                    )\n                    continue\n\n                # store valid phone number\n                self.targets.append(result[\"area\"] + result[\"line\"])\n\n        else:\n            # Send a message to ourselves\n            self.targets.append(self.source)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform VoIPms Notification.\"\"\"\n\n        if len(self.targets) == 0:\n            # There were no services to notify\n            self.logger.warning(\"There were no VoIPms targets to notify.\")\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"api_username\": self.email,\n            \"api_password\": self.password,\n            \"did\": self.source,\n            \"message\": body,\n            \"method\": \"sendSMS\",\n            # Gets filled in the loop below\n            \"dst\": None,\n        }\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Add target Phone #\n            payload[\"dst\"] = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"VoIPms GET URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"VoIPms Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            response = {\"status\": \"unknown\", \"message\": \"\"}\n\n            try:\n                r = requests.get(\n                    self.notify_url,\n                    params=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                with contextlib.suppress(\n                        AttributeError, TypeError, ValueError):\n                    # Load our JSON object if valid\n                    # ValueError = r.content is Unparsable\n                    # TypeError = r.content is None\n                    # AttributeError = r is None\n                    response = loads(r.content)\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyVoipms.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send VoIPms SMS notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                # VoIPms sends 200 OK even if there is an error\n                # check if status in response and if it is not success\n\n                if response is not None and response[\"status\"] != \"success\":\n                    self.logger.warning(\n                        \"Failed to send VoIPms SMS notification to {}: \"\n                        \"status: {}, message: {}\".format(\n                            target, response[\"status\"], response[\"message\"]\n                        )\n                    )\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n                else:\n                    self.logger.info(\n                        f\"Sent VoIPms SMS notification to {target}\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending VoIPms:{target} \"\n                    \"SMS notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.email,\n            self.password,\n            self.source,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        schemaStr = (\n            \"{schema}://{password}:{email}/{from_phone}/{targets}/?{params}\"\n        )\n        return schemaStr.format(\n            schema=self.secure_protocol,\n            email=self.email,\n            password=self.pprint(self.password, privacy, safe=\"\"),\n            from_phone=self.voip_ms_country_code\n            + self.pprint(self.source, privacy, safe=\"\"),\n            targets=\"/\".join([\n                self.voip_ms_country_code + NotifyVoipms.quote(x, safe=\"\")\n                for x in self.targets\n            ]),\n            params=NotifyVoipms.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        results[\"targets\"] = NotifyVoipms.split_path(results[\"fullpath\"])\n\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyVoipms.unquote(results[\"qsd\"][\"from\"])\n\n        elif results[\"targets\"]:\n            # The from phone no is the first entry in the list otherwise\n            results[\"source\"] = results[\"targets\"].pop(0)\n\n        # Swap user for pass since our input is: password:email\n        #   where email is user@hostname (or user@domain)\n        user = results[\"password\"]\n        password = results[\"user\"]\n        results[\"password\"] = password\n        results[\"user\"] = user\n\n        results[\"email\"] = \"{}@{}\".format(\n            NotifyVoipms.unquote(user),\n            NotifyVoipms.unquote(results[\"host\"]),\n        )\n\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyVoipms.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/vonage.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Sign-up with https://dashboard.nexmo.com/\n#\n# Get your (api) key and secret here:\n#   - https://dashboard.nexmo.com/getting-started-guide\n#\nimport contextlib\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyVonage(NotifyBase):\n    \"\"\"A wrapper for Vonage Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Vonage\"\n\n    # The services URL\n    service_url = \"https://dashboard.nexmo.com/\"\n\n    # The default protocol (nexmo kept for backwards compatibility)\n    secure_protocol = (\"vonage\", \"nexmo\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/vonage/\"\n\n    # Vonage uses the http protocol with JSON requests\n    notify_url = \"https://rest.nexmo.com/sms/json\"\n\n    # The maximum length of the body\n    body_maxlen = 160\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{apikey}:{secret}@{from_phone}\",\n        \"{schema}://{apikey}:{secret}@{from_phone}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"apikey\": {\n                \"name\": _(\"API Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n                \"private\": True,\n            },\n            \"secret\": {\n                \"name\": _(\"API Secret\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"from_phone\": {\n                \"name\": _(\"From Phone No\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^\\+?[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"source\",\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"from_phone\",\n            },\n            \"key\": {\n                \"alias_of\": \"apikey\",\n            },\n            \"secret\": {\n                \"alias_of\": \"secret\",\n            },\n            # Default Time To Live\n            # By default Vonage attempt delivery for 72 hours, however the\n            # maximum effective value depends on the operator and is typically\n            # 24 - 48 hours. We recommend this value should be kept at its\n            # default or at least 30 minutes.\n            \"ttl\": {\n                \"name\": _(\"ttl\"),\n                \"type\": \"int\",\n                \"default\": 900000,\n                \"min\": 20000,\n                \"max\": 604800000,\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    def __init__(\n        self, apikey, secret, source, targets=None, ttl=None, **kwargs\n    ):\n        \"\"\"Initialize Vonage Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # API Key (associated with project)\n        self.apikey = validate_regex(\n            apikey, *self.template_tokens[\"apikey\"][\"regex\"]\n        )\n        if not self.apikey:\n            msg = f\"An invalid Vonage API Key ({apikey}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # API Secret (associated with project)\n        self.secret = validate_regex(\n            secret, *self.template_tokens[\"secret\"][\"regex\"]\n        )\n        if not self.secret:\n            msg = f\"An invalid Vonage API Secret ({secret}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Set our Time to Live Flag\n        self.ttl = self.template_args[\"ttl\"][\"default\"]\n        with contextlib.suppress(ValueError, TypeError):\n            # update our ttl if we're dealing with an integer\n            self.ttl = int(ttl)\n\n        if (\n            self.ttl < self.template_args[\"ttl\"][\"min\"]\n            or self.ttl > self.template_args[\"ttl\"][\"max\"]\n        ):\n            msg = f\"The Vonage TTL specified ({self.ttl}) is out of range.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The Source Phone #\n        self.source = source\n\n        result = is_phone_no(source)\n        if not result:\n            msg = (\n                f\"The Account (From) Phone # specified ({source}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Store our parsed value\n        self.source = result[\"full\"]\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(result[\"full\"])\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Vonage Notification.\"\"\"\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"api_key\": self.apikey,\n            \"api_secret\": self.secret,\n            \"ttl\": self.ttl,\n            \"from\": self.source,\n            \"text\": body,\n            # The to gets populated in the loop below\n            \"to\": None,\n        }\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        if len(targets) == 0:\n            # No sources specified, use our own phone no\n            targets.append(self.source)\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our user\n            payload[\"to\"] = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"Vonage POST URL:\"\n                f\" {self.notify_url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"Vonage Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n\n            try:\n                r = requests.post(\n                    self.notify_url,\n                    data=payload,\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyVonage.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Vonage notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Vonage notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending Vonage:{target} \"\n                    \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol[0], self.apikey, self.secret)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"ttl\": str(self.ttl),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return \"{schema}://{key}:{secret}@{source}/{targets}/?{params}\".format(\n            schema=self.secure_protocol[0],\n            key=self.pprint(self.apikey, privacy, safe=\"\"),\n            secret=self.pprint(\n                self.secret, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            source=NotifyVonage.quote(self.source, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyVonage.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyVonage.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyVonage.split_path(results[\"fullpath\"])\n\n        # The hostname is our source number\n        results[\"source\"] = NotifyVonage.unquote(results[\"host\"])\n\n        # Get our account_side and auth_token from the user/pass config\n        results[\"apikey\"] = NotifyVonage.unquote(results[\"user\"])\n        results[\"secret\"] = NotifyVonage.unquote(results[\"password\"])\n\n        # API Key\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            # Extract the API Key from an argument\n            results[\"apikey\"] = NotifyVonage.unquote(results[\"qsd\"][\"key\"])\n\n        # API Secret\n        if \"secret\" in results[\"qsd\"] and len(results[\"qsd\"][\"secret\"]):\n            # Extract the API Secret from an argument\n            results[\"secret\"] = NotifyVonage.unquote(results[\"qsd\"][\"secret\"])\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"source\"] = NotifyVonage.unquote(results[\"qsd\"][\"from\"])\n        if \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"source\"] = NotifyVonage.unquote(results[\"qsd\"][\"source\"])\n\n        # Support the 'ttl' variable\n        if \"ttl\" in results[\"qsd\"] and len(results[\"qsd\"][\"ttl\"]):\n            results[\"ttl\"] = NotifyVonage.unquote(results[\"qsd\"][\"ttl\"])\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyVonage.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/webexteams.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# At the time I created this plugin, their website had lots of issues with the\n# Firefox Browser.  I fell back to Chrome and had no problems.\n\n# To use this plugin, you need to first access https://teams.webex.com and\n# make yourself an account if you don't already have one. You'll want to\n# create at least one 'space' before getting the 'incoming webhook'.\n#\n# Next you'll need to install the 'Incoming webhook' plugin found under\n# the 'other' category here: https://apphub.webex.com/integrations/\n\n# These links may not always work as time goes by and websites always\n# change, but at the time of creating this plugin this was a direct link\n# to it: https://apphub.webex.com/integrations/incoming-webhooks-cisco-systems\n\n# If you're logged in, you'll be able to click on the 'Connect' button. From\n# there you'll need to accept the permissions it will ask of you. Give the\n# webhook a name such as 'apprise'.\n# When you're complete, you will recieve a URL that looks something like this:\n# https://api.ciscospark.com/v1/webhooks/incoming/\\\n#       Y3lzY29zcGkyazovL3VzL1dFQkhPT0sajkkzYWU4fTMtMGE4Yy00\n#\n# The last part of the URL is all you need to be interested in. Think of this\n# url as:\n#   https://api.ciscospark.com/v1/webhooks/incoming/{token}\n#\n# You will need to assemble all of your URLs for this plugin to work as:\n#   wxteams://{token}\n#\n# Resources\n# - https://developer.webex.com/docs/api/basics - markdown/post syntax\n# - https://developer.cisco.com/ecosystem/webex/apps/\\\n#       incoming-webhooks-cisco-systems/ - Simple webhook example\n\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n# Extend HTTP Error Messages\n# Based on: https://developer.webex.com/docs/api/basics/rate-limiting\nWEBEX_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n    415: \"Unsuported media specified\",\n    429: \"To many consecutive requests were made.\",\n    503: \"Service is overloaded, try again later\",\n}\n\n\nclass NotifyWebexTeams(NotifyBase):\n    \"\"\"A wrapper for Webex Teams Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Cisco Webex Teams\"\n\n    # The services URL\n    service_url = \"https://webex.teams.com/\"\n\n    # The default secure protocol\n    secure_protocol = (\"wxteams\", \"webex\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/wxteams/\"\n\n    # Webex Teams uses the http protocol with JSON requests\n    notify_url = \"https://api.ciscospark.com/v1/webhooks/incoming/\"\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1000\n\n    # We don't support titles for Webex notifications\n    title_maxlen = 0\n\n    # Default to markdown; fall back to text\n    notify_format = NotifyFormat.MARKDOWN\n\n    # Define object templates\n    templates = (\"{schema}://{token}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]{80,160}$\", \"i\"),\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n        },\n    )\n\n    def __init__(self, token, **kwargs):\n        \"\"\"Initialize Webex Teams Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # The token associated with the account\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Webex Teams token specified ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Webex Teams Notification.\"\"\"\n\n        # Setup our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        # Prepare our URL\n        url = f\"{self.notify_url}/{self.token}\"\n\n        payload = {\n            (\n                \"markdown\"\n                if (self.notify_format == NotifyFormat.MARKDOWN)\n                else \"text\"\n            ): body,\n        }\n\n        self.logger.debug(\n            \"Webex Teams POST URL:\"\n            f\" {url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Webex Teams Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                url,\n                data=dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.no_content,\n            ):\n                # We had a problem\n                status_str = NotifyWebexTeams.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Webex Teams notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                return False\n\n            else:\n                self.logger.info(\"Sent Webex Teams notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Webex Teams notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol[0], self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{token}/?{params}\".format(\n            schema=self.secure_protocol[0],\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            params=NotifyWebexTeams.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Set our token if found as an argument\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            results[\"token\"] = \\\n                NotifyWebexTeams.unquote(results[\"qsd\"][\"token\"])\n\n        else:\n            # The first token is stored in the hostname\n            results[\"token\"] = NotifyWebexTeams.unquote(results[\"host\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://api.ciscospark.com/v1/webhooks/incoming/WEBHOOK_TOKEN\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://(api\\.ciscospark\\.com|webexapis\\.com)\"\n            r\"/v[1-9][0-9]*/webhooks/incoming/\"\n            r\"(?P<webhook_token>[A-Z0-9_-]+)/?\"\n            r\"(?P<params>\\?.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyWebexTeams.parse_url(\n                \"{schema}://{webhook_token}/{params}\".format(\n                    schema=NotifyWebexTeams.secure_protocol[0],\n                    webhook_token=result.group(\"webhook_token\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/wecombot.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# WeCom for PC\n#   1. On WeCom for PC, find the target WeCom group for receiving alarm\n#        notifications.\n#   2. Right-click the WeCom group. In the window that appears, click\n#        \"Add Group Bot\".\n#   3. In the window that appears, click Create a Bot.\n#   4. In the window that appears, enter a custom bot name and click Add.\n#   5. You will be provided a Webhook URL that looks like:\n#          https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abcd\n#\n# WeCom for Web\n#   1. On WebCom for Web, open the target WeCom group for receiving alarm\n#        notifications.\n#   2. Click the group settings icon in the upper-right corner.\n#   3. On the group settings page, choose Group Bots > Add a Bot.\n#   4. On the management page for adding bots, enter a custom name for the new\n#        bot.\n#   5. Click Add, copy the webhook address, and configure the API callback by\n#        following Step 2.\n\n# the URL will look something like this:\n#       https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=abcd\n#                                                             ^\n#                                                             |\n#                                                     webhook key\n#\n# This plugin also supports taking the URL (as identified above) directly\n# as well.\n\nfrom json import dumps\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyWeComBot(NotifyBase):\n    \"\"\"A wrapper for WeCom Bot Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"WeCom Bot\"\n\n    # The services URL\n    service_url = \"https://weixin.qq.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"wecombot\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/wecombot/\"\n\n    # Plain Text Notification URL\n    notify_url = \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}\"\n\n    # Define object templates\n    templates = (\"{schema}://{key}\",)\n\n    # The title is not used\n    title_maxlen = 0\n\n    # Define our template arguments\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            # The Bot Key can be found at the end of the webhook provided\n            # (?key=)\n            \"key\": {\n                \"name\": _(\"Bot Webhook Key\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            # You can optionally pass IRC colors into\n            \"key\": {\n                \"alias_of\": \"key\",\n            },\n        },\n    )\n\n    def __init__(self, key, **kwargs):\n        \"\"\"Initialize WeCom Bot Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # Assign our bot webhook\n        self.key = validate_regex(key, *self.template_tokens[\"key\"][\"regex\"])\n        if not self.key:\n            msg = f\"An invalid WeCom Bot Webhook Key ({key}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Prepare our notification URL now:\n        self.api_url = self.notify_url.format(\n            key=self.key,\n        )\n        return\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.key)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Prepare our parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{key}/?{params}\".format(\n            schema=self.secure_protocol,\n            key=self.pprint(self.key, privacy, safe=\"\"),\n            params=NotifyWeComBot.urlencode(params),\n        )\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Wrapper to _send since we can alert more then one channel.\"\"\"\n\n        # prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json; charset=utf-8\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"msgtype\": \"text\",\n            \"text\": {\n                \"content\": body,\n            },\n        }\n\n        self.logger.debug(\n            \"WeCom Bot GET URL:\"\n            f\" {self.api_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"WeCom Bot Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.api_url,\n                data=dumps(payload).encode(\"utf-8\"),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code != requests.codes.ok:\n                # We had a problem\n                status_str = NotifyWeComBot.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send WeCom Bot notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # Return; we're done\n                return False\n\n            else:\n                self.logger.info(\"Sent WeCom Bot notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending WeCom Bot notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # Return; we're done\n            return False\n\n        return True\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The first token is stored in the hostname\n        results[\"key\"] = NotifyWeComBot.unquote(results[\"host\"])\n\n        # The 'key' makes it easier to use yaml configuration\n        if \"key\" in results[\"qsd\"] and len(results[\"qsd\"][\"key\"]):\n            results[\"key\"] = NotifyWeComBot.unquote(results[\"qsd\"][\"key\"])\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=BOTKEY\n        \"\"\"\n\n        result = re.match(\n            r\"^https?://qyapi\\.weixin\\.qq\\.com/cgi-bin/webhook/send/?\\?key=\"\n            r\"(?P<key>[A-Z0-9_-]+)/?\"\n            r\"&?(?P<params>.+)?$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            return NotifyWeComBot.parse_url(\n                \"{schema}://{key}{params}\".format(\n                    schema=NotifyWeComBot.secure_protocol,\n                    key=result.group(\"key\"),\n                    params=(\n                        \"\"\n                        if not result.group(\"params\")\n                        else \"?\" + result.group(\"params\")\n                    ),\n                )\n            )\n\n        return None\n"
  },
  {
    "path": "apprise/plugins/whatsapp.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# API Source:\n#   https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages\n#\n# 1. Register a developer account with Meta:\n#  https://developers.facebook.com/docs/whatsapp/cloud-api/get-started\n# 2. Enable 2 Factor Authentication (2FA) with your account (if not done\n#  already)\n# 3. Create a App using WhatsApp Product.  There are 2 to create an app from\n#   Do NOT chose the WhatsApp Webhook one (choose the other)\n#\n#  When you click on the API Setup section of your new app you need to record\n#  both the access token and the From Phone Number ID.  Note that this not the\n#  from phone number itself, but it's ID.  It's displayed below and contains\n#  way more numbers then your typical phone number\n\nfrom json import dumps, loads\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_phone_no, parse_phone_no, validate_regex\nfrom .base import NotifyBase\n\n\nclass NotifyWhatsApp(NotifyBase):\n    \"\"\"A wrapper for WhatsApp Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"WhatsApp\"\n\n    # The services URL\n    service_url = (\n        \"https://developers.facebook.com/docs/whatsapp/cloud-api/get-started\"\n    )\n\n    # All notification requests are secure\n    secure_protocol = \"whatsapp\"\n\n    # Allow 300 requests per minute.\n    # 60/300 = 0.2\n    request_rate_per_sec = 0.20\n\n    # Facebook Graph version\n    fb_graph_version = \"v17.0\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/whatsapp/\"\n\n    # WhatsApp Message Notification URL\n    notify_url = \"https://graph.facebook.com/{fb_ver}/{phone_id}/messages\"\n\n    # The maximum length of the body\n    body_maxlen = 1024\n\n    # A title can not be used for SMS Messages.  Setting this to zero will\n    # cause any title (if defined) to get placed into the message body.\n    title_maxlen = 0\n\n    # Define object templates\n    templates = (\n        \"{schema}://{token}@{from_phone_id}/{targets}\",\n        \"{schema}://{template}:{token}@{from_phone_id}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"Access Token\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9]+$\", \"i\"),\n            },\n            \"template\": {\n                \"name\": _(\"Template Name\"),\n                \"type\": \"string\",\n                \"required\": False,\n                \"regex\": (r\"^[^\\s]+$\", \"i\"),\n            },\n            \"from_phone_id\": {\n                \"name\": _(\"From Phone ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[0-9]+$\", \"i\"),\n            },\n            \"language\": {\n                \"name\": _(\"Language\"),\n                \"type\": \"string\",\n                \"default\": \"en_US\",\n                \"regex\": (r\"^[^0-9\\s]+$\", \"i\"),\n            },\n            \"target_phone\": {\n                \"name\": _(\"Target Phone No\"),\n                \"type\": \"string\",\n                \"prefix\": \"+\",\n                \"regex\": (r\"^[0-9\\s)(+-]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"from\": {\n                \"alias_of\": \"from_phone_id\",\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n            \"template\": {\n                \"alias_of\": \"template\",\n            },\n            \"lang\": {\n                \"alias_of\": \"language\",\n            },\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n        },\n    )\n\n    # Our supported mappings and component keys\n    component_key_re = re.compile(\n        r\"(?P<key>((?P<id>[1-9][0-9]*)|(?P<map>body|type)))\", re.IGNORECASE\n    )\n\n    # Define any kwargs we're using\n    template_kwargs = {\n        \"template_mapping\": {\n            \"name\": _(\"Template Mapping\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        token,\n        from_phone_id,\n        template=None,\n        targets=None,\n        language=None,\n        template_mapping=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize WhatsApp Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # The Access Token associated with the account\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"An invalid WhatsApp Access Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The From Phone ID associated with the account\n        self.from_phone_id = validate_regex(\n            from_phone_id, *self.template_tokens[\"from_phone_id\"][\"regex\"]\n        )\n        if not self.from_phone_id:\n            msg = (\n                \"An invalid WhatsApp From Phone ID \"\n                f\"({from_phone_id}) was specified.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # The template to associate with the message\n        if template:\n            self.template = validate_regex(\n                template, *self.template_tokens[\"template\"][\"regex\"]\n            )\n            if not self.template:\n                msg = (\n                    \"An invalid WhatsApp Template Name \"\n                    f\"({template}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            # The Template language Code to use\n            if language:\n                self.language = validate_regex(\n                    language, *self.template_tokens[\"language\"][\"regex\"]\n                )\n                if not self.language:\n                    msg = (\n                        \"An invalid WhatsApp Template Language Code \"\n                        f\"({language}) was specified.\"\n                    )\n                    self.logger.warning(msg)\n                    raise TypeError(msg)\n            else:\n                self.language = self.template_tokens[\"language\"][\"default\"]\n        else:\n            #\n            # Message Mode\n            #\n            self.template = None\n\n        # Parse our targets\n        self.targets = []\n\n        for target in parse_phone_no(targets):\n            # Validate targets and drop bad ones:\n            result = is_phone_no(target)\n            if not result:\n                self.logger.warning(\n                    f\"Dropped invalid phone # ({target}) specified.\",\n                )\n                continue\n\n            # store valid phone number\n            self.targets.append(\"+{}\".format(result[\"full\"]))\n\n        self.template_mapping = {}\n        if template_mapping:\n            # Store our extra payload entries\n            self.template_mapping.update(template_mapping)\n\n        # Validate Mapping and prepare Components\n        self.components = {}\n        self.component_keys = []\n        for key, val in self.template_mapping.items():\n            matched = self.component_key_re.match(key)\n            if not matched:\n                msg = (\n                    f\"An invalid Template Component ID ({key}) was specified.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            if matched.group(\"id\"):\n                #\n                # Manual Component Assigment (by id)\n                #\n                index = matched.group(\"id\")\n                map_to = {\n                    \"type\": \"text\",\n                    \"text\": val,\n                }\n\n            else:  # matched.group('map')\n                map_to = matched.group(\"map\").lower()\n                matched = self.component_key_re.match(val)\n                if not (matched and matched.group(\"id\")):\n                    msg = (\n                        \"An invalid Template Component Mapping \"\n                        f\"(:{key}={val}) was specified.\"\n                    )\n                    self.logger.warning(msg)\n                    raise TypeError(msg)\n                index = matched.group(\"id\")\n\n            if index in self.components:\n                msg = (\n                    \"The Template Component index \"\n                    f\"({key}) was already assigned.\"\n                )\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n            self.components[index] = map_to\n            self.component_keys = self.components.keys()\n            # Adjust sorting and assume that the user put the order correctly;\n            # if not Facebook just won't be very happy and will reject the\n            # message\n            sorted(self.component_keys)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform WhatsApp Notification.\"\"\"\n\n        if not self.targets:\n            self.logger.warning(\n                \"There are no valid WhatsApp targets to notify.\"\n            )\n            return False\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our URL\n        url = self.notify_url.format(\n            fb_ver=self.fb_graph_version,\n            phone_id=self.from_phone_id,\n        )\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": f\"Bearer {self.token}\",\n        }\n\n        payload = {\n            \"messaging_product\": \"whatsapp\",\n            # The To gets populated in the loop below\n            \"to\": None,\n        }\n\n        if not self.template:\n            #\n            # Send Message\n            #\n            payload.update({\n                \"recipient_type\": \"individual\",\n                \"type\": \"text\",\n                \"text\": {\"body\": body},\n            })\n\n        else:\n            #\n            # Send Template\n            #\n            payload.update({\n                \"type\": \"template\",\n                \"template\": {\n                    \"name\": self.template,\n                    \"language\": {\"code\": self.language},\n                },\n            })\n\n            if self.components:\n                payload[\"template\"][\"components\"] = [{\n                    \"type\": \"body\",\n                    \"parameters\": [],\n                }]\n                for key in self.component_keys:\n                    if isinstance(self.components[key], dict):\n                        # Manual Assignment\n                        payload[\"template\"][\"components\"][0][\n                            \"parameters\"\n                        ].append(self.components[key])\n                        continue\n\n                    # Mapping of body and/or notify type\n                    payload[\"template\"][\"components\"][0][\"parameters\"].append({\n                        \"type\": \"text\",\n                        \"text\": (\n                            body\n                            if self.components[key] == \"body\"\n                            else notify_type.value\n                        ),\n                    })\n\n        # Create a copy of the targets list\n        targets = list(self.targets)\n\n        while len(targets):\n            # Get our target to notify\n            target = targets.pop(0)\n\n            # Prepare our user\n            payload[\"to\"] = target\n\n            # Some Debug Logging\n            self.logger.debug(\n                \"WhatsApp POST URL:\"\n                f\" {url} (cert_verify={self.verify_certificate})\"\n            )\n            self.logger.debug(f\"WhatsApp Payload: {payload}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    url,\n                    data=dumps(payload),\n                    headers=headers,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n\n                if r.status_code not in (\n                    requests.codes.created,\n                    requests.codes.ok,\n                ):\n                    # We had a problem\n                    status_str = NotifyBase.http_response_code_lookup(\n                        r.status_code\n                    )\n\n                    # set up our status code to use\n                    status_code = r.status_code\n\n                    try:\n                        # Update our status response if we can\n                        json_response = loads(r.content)\n                        status_code = json_response[\"error\"].get(\n                            \"code\", status_code\n                        )\n                        status_str = json_response[\"error\"].get(\n                            \"message\", status_str\n                        )\n\n                    except (AttributeError, TypeError, ValueError, KeyError):\n                        # KeyError = r.content is parseable but does not\n                        #            contain 'error'\n                        # ValueError = r.content is Unparsable\n                        # TypeError = r.content is None\n                        # AttributeError = r is None\n\n                        # We could not parse JSON response.\n                        # We will just use the status we already have.\n                        pass\n\n                    self.logger.warning(\n                        \"Failed to send WhatsApp notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(\n                        f\"Sent WhatsApp notification to {target}.\"\n                    )\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    f\"A Connection error occurred sending WhatsApp:{target} \"\n                    + \"notification.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.from_phone_id, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {}\n        if self.template:\n            # Add language to our URL\n            params[\"lang\"] = self.language\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        # Payload body extras prefixed with a ':' sign\n        # Append our payload extras into our parameters\n        params.update({f\":{k}\": v for k, v in self.template_mapping.items()})\n\n        return (\n            \"{schema}://{template}{token}@{from_id}/{targets}/?{params}\"\n            .format(\n                schema=self.secure_protocol,\n                from_id=self.pprint(self.from_phone_id, privacy, safe=\"\"),\n                token=self.pprint(self.token, privacy, safe=\"\"),\n                template=(\n                    \"\"\n                    if not self.template\n                    else \"{}:\".format(\n                        NotifyWhatsApp.quote(self.template, safe=\"\")\n                    )\n                ),\n                targets=\"/\".join(\n                    [NotifyWhatsApp.quote(x, safe=\"\") for x in self.targets]\n                ),\n                params=NotifyWhatsApp.urlencode(params),\n            )\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        targets = len(self.targets)\n        return targets if targets > 0 else 1\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyWhatsApp.split_path(results[\"fullpath\"])\n\n        # The hostname is our From Phone ID\n        results[\"from_phone_id\"] = NotifyWhatsApp.unquote(results[\"host\"])\n\n        # Determine if we have a Template, otherwise load our token\n        if results[\"password\"]:\n            #\n            # Template Mode\n            #\n            results[\"template\"] = NotifyWhatsApp.unquote(results[\"user\"])\n            results[\"token\"] = NotifyWhatsApp.unquote(results[\"password\"])\n\n        else:\n            #\n            # Message Mode\n            #\n            results[\"token\"] = NotifyWhatsApp.unquote(results[\"user\"])\n\n        # Access token\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Extract the account sid from an argument\n            results[\"token\"] = NotifyWhatsApp.unquote(results[\"qsd\"][\"token\"])\n\n        # Template\n        if \"template\" in results[\"qsd\"] and len(results[\"qsd\"][\"template\"]):\n            results[\"template\"] = results[\"qsd\"][\"template\"]\n\n        # Template Language\n        if \"lang\" in results[\"qsd\"] and len(results[\"qsd\"][\"lang\"]):\n            results[\"language\"] = results[\"qsd\"][\"lang\"]\n\n        # Support the 'from'  and 'source' variable so that we can support\n        # targets this way too.\n        # The 'from' makes it easier to use yaml configuration\n        if \"from\" in results[\"qsd\"] and len(results[\"qsd\"][\"from\"]):\n            results[\"from_phone_id\"] = NotifyWhatsApp.unquote(\n                results[\"qsd\"][\"from\"]\n            )\n        if \"source\" in results[\"qsd\"] and len(results[\"qsd\"][\"source\"]):\n            results[\"from_phone_id\"] = NotifyWhatsApp.unquote(\n                results[\"qsd\"][\"source\"]\n            )\n\n        # Support the 'to' variable so that we can support targets this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyWhatsApp.parse_phone_no(\n                results[\"qsd\"][\"to\"]\n            )\n\n        # store any additional payload extra's defined\n        results[\"template_mapping\"] = {\n            NotifyWhatsApp.unquote(x): NotifyWhatsApp.unquote(y)\n            for x, y in results[\"qsd:\"].items()\n        }\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/windows.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nfrom time import sleep\n\nfrom ..common import NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool\nfrom .base import NotifyBase\n\n# Default our global support flag\nNOTIFY_WINDOWS_SUPPORT_ENABLED = False\n\ntry:\n    # 3rd party modules (Windows Only)\n    import win32api\n    import win32con\n    import win32gui\n\n    # We're good to go!\n    NOTIFY_WINDOWS_SUPPORT_ENABLED = True\n\nexcept ImportError:\n    # No problem; we just simply can't support this plugin because we're\n    # either using Linux, or simply do not have pywin32 installed.\n    pass\n\n\nclass NotifyWindows(NotifyBase):\n    \"\"\"A wrapper for local Windows Notifications.\"\"\"\n\n    # Set our global enabled flag\n    enabled = NOTIFY_WINDOWS_SUPPORT_ENABLED\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"details\": _(\"A local Microsoft Windows environment is required.\")\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Windows Notification\"\n\n    # The default protocol\n    protocol = \"windows\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/windows/\"\n\n    # Disable throttle rate for Windows requests since they are normally\n    # local anyway\n    request_rate_per_sec = 0\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_128\n\n    # Limit results to just the first 2 line otherwise there is just to much\n    # content to display\n    body_max_line_count = 2\n\n    # The number of seconds to display the popup for\n    default_popup_duration_sec = 12\n\n    # No URL Identifier will be defined for this service as there simply isn't\n    # enough details to uniquely identify one dbus:// from another.\n    url_identifier = False\n\n    # Define object templates\n    templates = (\"{schema}://\",)\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"duration\": {\n                \"name\": _(\"Duration\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"default\": 12,\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n        },\n    )\n\n    def __init__(self, include_image=True, duration=None, **kwargs):\n        \"\"\"Initialize Windows Object.\"\"\"\n\n        super().__init__(**kwargs)\n\n        # Number of seconds to display notification for\n        self.duration = (\n            self.default_popup_duration_sec\n            if not (isinstance(duration, int) and duration > 0)\n            else duration\n        )\n\n        # Define our handler\n        self.hwnd = None\n\n        # Track whether or not we want to send an image with our notification\n        # or not.\n        self.include_image = include_image\n\n    def _on_destroy(self, hwnd, msg, wparam, lparam):\n        \"\"\"Destroy callback function.\"\"\"\n\n        nid = (self.hwnd, 0)\n        win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, nid)\n        win32api.PostQuitMessage(0)\n\n        return 0\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Windows Notification.\"\"\"\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            # Register destruction callback\n            message_map = {\n                win32con.WM_DESTROY: self._on_destroy,\n            }\n\n            # Register the window class.\n            self.wc = win32gui.WNDCLASS()\n            self.hinst = self.wc.hInstance = win32api.GetModuleHandle(None)\n            self.wc.lpszClassName = \"PythonTaskbar\"\n            self.wc.lpfnWndProc = message_map\n            self.classAtom = win32gui.RegisterClass(self.wc)\n\n            # Styling and window type\n            style = win32con.WS_OVERLAPPED | win32con.WS_SYSMENU\n            self.hwnd = win32gui.CreateWindow(\n                self.classAtom,\n                \"Taskbar\",\n                style,\n                0,\n                0,\n                win32con.CW_USEDEFAULT,\n                win32con.CW_USEDEFAULT,\n                0,\n                0,\n                self.hinst,\n                None,\n            )\n            win32gui.UpdateWindow(self.hwnd)\n\n            # image path (if configured to acquire)\n            icon_path = (\n                None\n                if not self.include_image\n                else self.image_path(notify_type, extension=\".ico\")\n            )\n\n            if icon_path:\n                icon_flags = win32con.LR_LOADFROMFILE | win32con.LR_DEFAULTSIZE\n\n                try:\n                    hicon = win32gui.LoadImage(\n                        self.hinst,\n                        icon_path,\n                        win32con.IMAGE_ICON,\n                        0,\n                        0,\n                        icon_flags,\n                    )\n\n                except Exception as e:\n                    self.logger.warning(\n                        \"Could not load windows notification icon\"\n                        f\" ({icon_path}): {e}\"\n                    )\n\n                    # disable icon\n                    hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION)\n            else:\n                # disable icon\n                hicon = win32gui.LoadIcon(0, win32con.IDI_APPLICATION)\n\n            # Taskbar icon\n            flags = win32gui.NIF_ICON | win32gui.NIF_MESSAGE | win32gui.NIF_TIP\n            nid = (\n                self.hwnd,\n                0,\n                flags,\n                win32con.WM_USER + 20,\n                hicon,\n                \"Tooltip\",\n            )\n            win32gui.Shell_NotifyIcon(win32gui.NIM_ADD, nid)\n            win32gui.Shell_NotifyIcon(\n                win32gui.NIM_MODIFY,\n                (\n                    self.hwnd,\n                    0,\n                    win32gui.NIF_INFO,\n                    win32con.WM_USER + 20,\n                    hicon,\n                    \"Balloon Tooltip\",\n                    body,\n                    200,\n                    title,\n                ),\n            )\n\n            # take a rest then destroy\n            sleep(self.duration)\n            win32gui.DestroyWindow(self.hwnd)\n            win32gui.UnregisterClass(self.wc.lpszClassName, None)\n\n            self.logger.info(\"Sent Windows notification.\")\n\n        except Exception as e:\n            self.logger.warning(\"Failed to send Windows notification.\")\n            self.logger.debug(\"Windows Exception: {}\", str(e))\n            return False\n\n        return True\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"duration\": str(self.duration),\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        return f\"{self.protocol}://?{NotifyWindows.urlencode(params)}\"\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"There are no parameters nessisary for this protocol; simply having\n        windows:// is all you need.\n\n        This function just makes sure that is in place.\n        \"\"\"\n\n        results = NotifyBase.parse_url(url, verify_host=False)\n\n        # Include images with our message\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\"image\", True)\n        )\n\n        # Set duration\n        with contextlib.suppress(TypeError, ValueError):\n            # Update our duration if we're dealing with an integer\n            results[\"duration\"] = int(results[\"qsd\"].get(\"duration\"))\n\n        # return results\n        return results\n"
  },
  {
    "path": "apprise/plugins/workflows.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you need to create a MS Teams Azure Webhook Workflow:\n#  https://support.microsoft.com/en-us/office/browse-and-add-workflows-\\\n#       in-microsoft-teams-4998095c-8b72-4b0e-984c-f2ad39e6ba9a\n\n# Your webhook will look somthing like this (legacy):\n# https://prod-161.westeurope.logic.azure.com:443/\\\n#       workflows/643e69f83c8944438d68119179a10a64/triggers/manual/\\\n#       paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&\\\n#       sv=1.0&sig=KODuebWbDGYFr0z0eu-6Rj8aUKz7108W3wrNJZxFE5A\n#\n# Or it may now look something like this:\n# https://prod-161.westeurope.logic.azure.com:443/\\\n#       powerautomate/automations/direct/\\\n#       workflows/643e69f83c8944438d68119179a10a64/triggers/manual/\\\n#       paths/invoke?api-version=2022-03-01-preview&sp=%2Ftriggers%2Fmanual%2F\\\n#       run&sv=1.0&sig=KODuebWbDGYFr0z0eu-6Rj8aUKz7108W3wrNJZxFE5A\n#\n# Yes... The URL is that big... But it looks like this (greatly simplified):\n# https://HOST:PORT/workflows/ABCD/triggers/manual/path/...sig=DEFG\n#          ^    ^                ^                              ^\n#          |    |                |                              |\n#  These are important <---------^------------------------------^\n#\n#\n# Apprise can support this webhook as is (directly passed into it)\n# Alternatively it can be shortend to:\n\n# These 3 tokens need to be placed in the URL after the Team\n#   workflows://HOST:PORT/ABCD/DEFG/\n#\n\nimport json\nfrom json.decoder import JSONDecodeError\nimport re\n\nimport requests\n\nfrom ..apprise_attachment import AppriseAttachment\nfrom ..common import NotifyFormat, NotifyImageSize, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import parse_bool, validate_regex\nfrom ..utils.templates import TemplateType, apply_template\nfrom .base import NotifyBase\n\n\nclass APIVersion:\n    \"\"\"\n    Define API Versions\n    \"\"\"\n    WORKFLOW = \"2016-06-01\"\n    POWER_AUTOMATE = \"2022-03-01-preview\"\n\n\nclass NotifyWorkflows(NotifyBase):\n    \"\"\"A wrapper for Microsoft Workflows (MS Teams) Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Power Automate / Workflows (for MSTeams)\"\n\n    # The services URL\n    service_url = (\n        \"https://www.microsoft.com/power-platform/products/power-automate\"\n    )\n\n    # The default secure protocol\n    secure_protocol = (\"workflow\", \"workflows\")\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/workflows/\"\n\n    # Allows the user to specify the NotifyImageSize object\n    image_size = NotifyImageSize.XY_32\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 1000\n\n    # Default Notification Format\n    notify_format = NotifyFormat.MARKDOWN\n\n    # There is no reason we should exceed 35KB when reading in a JSON file.\n    # If it is more than this, then it is not accepted\n    max_workflows_template_size = 35000\n\n    # Adaptive Card Version\n    adaptive_card_version = \"1.4\"\n\n    # Define object templates\n    templates = (\n        \"{schema}://{host}/{workflow}/{signature}\",\n        \"{schema}://{host}:{port}/{workflow}/{signature}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            # workflow identifier\n            \"workflow\": {\n                \"name\": _(\"Workflow ID\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9_-]+$\", \"i\"),\n            },\n            # Signature\n            \"signature\": {\n                \"name\": _(\"Signature\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n                \"regex\": (r\"^[a-z0-9_-]+$\", \"i\"),\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"id\": {\n                \"alias_of\": \"workflow\",\n            },\n            \"image\": {\n                \"name\": _(\"Include Image\"),\n                \"type\": \"bool\",\n                \"default\": True,\n                \"map_to\": \"include_image\",\n            },\n            \"pa\": {\n                \"name\": _(\"Use Power Automate URL\"),\n                \"type\": \"bool\",\n                \"default\": False,\n                \"map_to\": \"power_automate\",\n            },\n            \"powerautomate\": {\"alias_of\": \"pa\"},\n            \"wrap\": {\n                \"name\": _(\"Wrap Text\"),\n                \"type\": \"bool\",\n                \"default\": True,\n            },\n            \"template\": {\n                \"name\": _(\"Template Path\"),\n                \"type\": \"string\",\n                \"private\": True,\n            },\n            # Below variable shortforms are taken from the Workflows webhook\n            # for consistency\n            \"sig\": {\n                \"alias_of\": \"signature\",\n            },\n            \"ver\": {\n                \"name\": _(\"API Version\"),\n                \"type\": \"string\",\n                \"map_to\": \"version\",\n            },\n            \"api-version\": {\"alias_of\": \"ver\"},\n        },\n    )\n\n    # Define our token control\n    template_kwargs = {\n        \"tokens\": {\n            \"name\": _(\"Template Tokens\"),\n            \"prefix\": \":\",\n        },\n    }\n\n    def __init__(\n        self,\n        workflow,\n        signature,\n        include_image=None,\n        power_automate=None,\n        version=None,\n        template=None,\n        tokens=None,\n        wrap=None,\n        **kwargs,\n    ):\n        \"\"\"Initialize Microsoft Workflows Object.\"\"\"\n        super().__init__(**kwargs)\n\n        self.workflow = validate_regex(\n            workflow, *self.template_tokens[\"workflow\"][\"regex\"]\n        )\n        if not self.workflow:\n            msg = f\"An invalid Workflows ID ({workflow}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.signature = validate_regex(\n            signature, *self.template_tokens[\"signature\"][\"regex\"]\n        )\n        if not self.signature:\n            msg = f\"An invalid Signature ({signature}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Place a thumbnail image inline with the message body\n        self.include_image = bool(\n            include_image\n            if include_image is not None\n            else self.template_args[\"image\"][\"default\"]\n        )\n\n        # Power Automate status\n        self.power_automate = bool(\n            power_automate\n            if power_automate is not None\n            else self.template_args[\"pa\"][\"default\"]\n        )\n\n        # Wrap Text\n        self.wrap = bool(\n            wrap if wrap is not None else self.template_args[\"wrap\"][\"default\"]\n        )\n\n        # Our template object is just an AppriseAttachment object\n        self.template = AppriseAttachment(asset=self.asset)\n        if template:\n            # Add our definition to our template\n            self.template.add(template)\n            # Enforce maximum file size\n            self.template[0].max_file_size = self.max_workflows_template_size\n\n        # Prepare Version\n        # The default is taken from the template_args\n        # - If using power_automate, the API version required is different.\n        default_api_version = (\n            APIVersion.POWER_AUTOMATE\n            if self.power_automate else APIVersion.WORKFLOW)\n\n        self.api_version = (\n            version\n            if version is not None\n            else default_api_version\n        )\n\n        # Template functionality\n        self.tokens = {}\n        if isinstance(tokens, dict):\n            self.tokens.update(tokens)\n\n        elif tokens:\n            msg = (\n                \"The specified Workflows Template Tokens \"\n                f\"({tokens}) are not identified as a dictionary.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # else:  NoneType - this is okay\n        return\n\n    def gen_payload(\n        self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs\n    ):\n        \"\"\"This function generates our payload whether it be the generic one\n        Apprise generates by default, or one provided by a specified external\n        template.\"\"\"\n\n        # Acquire our to-be footer icon if configured to do so\n        image_url = (\n            None if not self.include_image else self.image_url(notify_type)\n        )\n\n        body_content = []\n        if image_url:\n            body_content.append({\n                \"type\": \"Image\",\n                \"url\": image_url,\n                \"height\": \"32px\",\n                \"altText\": notify_type.value,\n            })\n\n        if title:\n            body_content.append({\n                \"type\": \"TextBlock\",\n                \"text\": f\"{title}\",\n                \"style\": \"heading\",\n                \"weight\": \"Bolder\",\n                \"size\": \"Large\",\n                \"id\": \"title\",\n            })\n\n        body_content.append({\n            \"type\": \"TextBlock\",\n            \"text\": body,\n            \"style\": \"default\",\n            \"wrap\": self.wrap,\n            \"id\": \"body\",\n        })\n\n        if not self.template:\n            # By default we use a generic working payload if there was\n            # no template specified\n            schema = \"http://adaptivecards.io/schemas/adaptive-card.json\"\n            payload = {\n                \"type\": \"message\",\n                \"attachments\": [{\n                    \"contentType\": \"application/vnd.microsoft.card.adaptive\",\n                    \"contentUrl\": None,\n                    \"content\": {\n                        \"$schema\": schema,\n                        \"type\": \"AdaptiveCard\",\n                        \"version\": self.adaptive_card_version,\n                        \"body\": body_content,\n                        # Additionally\n                        \"msteams\": {\"width\": \"full\"},\n                    },\n                }],\n            }\n\n            return payload\n\n        # If our code reaches here, then we generate ourselves the payload\n        template = self.template[0]\n        if not template:\n            # We could not access the attachment\n            self.logger.error(\n                \"Could not access Workflow template\"\n                f\" {template.url(privacy=True)}.\"\n            )\n            return False\n\n        # Take a copy of our token dictionary\n        tokens = self.tokens.copy()\n\n        # Apply some defaults template values\n        tokens[\"app_body\"] = body\n        tokens[\"app_title\"] = title\n        tokens[\"app_type\"] = notify_type.value\n        tokens[\"app_id\"] = self.app_id\n        tokens[\"app_desc\"] = self.app_desc\n        tokens[\"app_color\"] = self.color(notify_type)\n        tokens[\"app_image_url\"] = image_url\n        tokens[\"app_url\"] = self.app_url\n\n        # Enforce Application mode\n        tokens[\"app_mode\"] = TemplateType.JSON\n\n        try:\n            with open(template.path) as fp:\n                content = json.loads(apply_template(fp.read(), **tokens))\n\n        except OSError:\n            self.logger.error(\n                f\"MSTeam template {template.url(privacy=True)} could not be\"\n                \" read.\"\n            )\n            return None\n\n        except JSONDecodeError as e:\n            self.logger.error(\n                f\"MSTeam template {template.url(privacy=True)} contains\"\n                \" invalid JSON.\"\n            )\n            self.logger.debug(f\"JSONDecodeError: {e}\")\n            return None\n\n        return content\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Microsoft Teams Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n        }\n\n        params = {\n            \"api-version\": self.api_version,\n            \"sp\": \"/triggers/manual/run\",\n            \"sv\": \"1.0\",\n            \"sig\": self.signature,\n        }\n\n        # The URL changes depending on whether we're using power automate or\n        # not\n        path = (\n            \"/powerautomate/automations/direct\"\n            if self.power_automate\n            else \"\"\n        )\n\n        notify_url = (\n            \"https://{host}{port}{path}/workflows/{workflow}/\"\n            \"triggers/manual/paths/invoke\".format(\n                host=self.host,\n                port=\"\" if not self.port else f\":{self.port}\",\n                path=path,\n                workflow=self.workflow,\n            )\n        )\n\n        # Generate our payload if it's possible\n        payload = self.gen_payload(\n            body=body, title=title, notify_type=notify_type, **kwargs\n        )\n        if not payload:\n            # No need to present a reason; that will come from the\n            # gen_payload() function itself\n            return False\n\n        self.logger.debug(\n            \"Workflows POST URL:\"\n            f\" {notify_url} (cert_verify={self.verify_certificate!r})\"\n        )\n        self.logger.debug(f\"Workflows Payload: {payload!s}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n        try:\n            r = requests.post(\n                notify_url,\n                params=params,\n                data=json.dumps(payload),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n            if r.status_code not in (\n                requests.codes.ok,\n                requests.codes.accepted,\n            ):\n                # We had a problem\n                status_str = NotifyWorkflows.http_response_code_lookup(\n                    r.status_code\n                )\n\n                self.logger.warning(\n                    \"Failed to send Workflows notification: \"\n                    \"{}{}error={}.\".format(\n                        status_str, \", \" if status_str else \"\", r.status_code\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                # We failed\n                return False\n\n            else:\n                self.logger.info(\"Sent Workflows notification.\")\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending Workflows notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            # We failed\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol[0],\n            self.host,\n            self.port,\n            self.workflow,\n            self.signature,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = {\n            \"image\": \"yes\" if self.include_image else \"no\",\n            \"wrap\": \"yes\" if self.wrap else \"no\",\n            \"pa\": \"yes\" if self.power_automate else \"no\",\n        }\n\n        if self.template:\n            params[\"template\"] = NotifyWorkflows.quote(\n                self.template[0].url(), safe=\"\"\n            )\n\n        # Store our version if it differs from default\n        if (self.api_version != APIVersion.WORKFLOW\n            and not self.power_automate) or (\n                self.api_version != APIVersion.POWER_AUTOMATE\n                and self.power_automate):\n            # But only do so if we're not using power automate with the\n            # default version for that.\n            params[\"ver\"] = self.api_version\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n        # Store any template entries if specified\n        params.update({f\":{k}\": v for k, v in self.tokens.items()})\n\n        return (\n            \"{schema}://{host}{port}/{workflow}/{signature}/?{params}\".format(\n                schema=self.secure_protocol[0],\n                host=self.host,\n                port=\"\" if not self.port else f\":{self.port}\",\n                workflow=self.pprint(self.workflow, privacy, safe=\"\"),\n                signature=self.pprint(self.signature, privacy, safe=\"\"),\n                params=NotifyWorkflows.urlencode(params),\n            )\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n\n        results = NotifyBase.parse_url(url)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # store values if provided\n        entries = NotifyWorkflows.split_path(results[\"fullpath\"])\n\n        # Display image?\n        results[\"include_image\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"image\", NotifyWorkflows.template_args[\"image\"][\"default\"]\n            )\n        )\n\n        # Support Power Automate URL\n        results[\"power_automate\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"powerautomate\",\n                results[\"qsd\"].get(\n                    \"pa\",\n                    NotifyWorkflows.template_args[\"pa\"][\"default\"]\n                )\n            )\n        )\n\n        # Wrap Text?\n        results[\"wrap\"] = parse_bool(\n            results[\"qsd\"].get(\n                \"wrap\", NotifyWorkflows.template_args[\"wrap\"][\"default\"]\n            )\n        )\n\n        # Template Handling\n        if \"template\" in results[\"qsd\"] and results[\"qsd\"][\"template\"]:\n            results[\"template\"] = NotifyWorkflows.unquote(\n                results[\"qsd\"][\"template\"]\n            )\n\n        if \"workflow\" in results[\"qsd\"] and results[\"qsd\"][\"workflow\"]:\n            results[\"workflow\"] = NotifyWorkflows.unquote(\n                results[\"qsd\"][\"workflow\"]\n            )\n\n        elif \"id\" in results[\"qsd\"] and results[\"qsd\"][\"id\"]:\n            results[\"workflow\"] = NotifyWorkflows.unquote(results[\"qsd\"][\"id\"])\n\n        else:\n            results[\"workflow\"] = (\n                None\n                if not entries\n                else NotifyWorkflows.unquote(entries.pop(0))\n            )\n\n        # Signature\n        if \"signature\" in results[\"qsd\"] and results[\"qsd\"][\"signature\"]:\n            results[\"signature\"] = NotifyWorkflows.unquote(\n                results[\"qsd\"][\"signature\"]\n            )\n\n        elif \"sig\" in results[\"qsd\"] and results[\"qsd\"][\"sig\"]:\n            results[\"signature\"] = NotifyWorkflows.unquote(\n                results[\"qsd\"][\"sig\"]\n            )\n\n        else:\n            # Read information from path\n            results[\"signature\"] = (\n                None\n                if not entries\n                else NotifyWorkflows.unquote(entries.pop(0))\n            )\n\n        # Version\n        if \"api-version\" in results[\"qsd\"] and results[\"qsd\"][\"api-version\"]:\n            results[\"version\"] = NotifyWorkflows.unquote(\n                results[\"qsd\"][\"api-version\"]\n            )\n\n        elif \"ver\" in results[\"qsd\"] and results[\"qsd\"][\"ver\"]:\n            results[\"version\"] = NotifyWorkflows.unquote(results[\"qsd\"][\"ver\"])\n\n        # Store our tokens\n        results[\"tokens\"] = results[\"qsd:\"]\n\n        return results\n\n    @staticmethod\n    def parse_native_url(url):\n        \"\"\"\n        Support parsing the webhook straight out of workflows\n            https://HOST:443/workflows/WORKFLOWID/triggers/manual/paths/invoke\n            or\n            https://HOST:443/powerautomate/automations/direct/workflows\n            /WORKFLOWID/triggers/manual/paths/invoke\n        \"\"\"\n\n        # Match our workflows webhook URL and re-assemble\n        result = re.match(\n            r\"^https?://(?P<host>[A-Z0-9_.-]+)\"\n            r\"(?P<port>:[1-9][0-9]{0,5})?\"\n            # The new URL structure includes /powerautomate/automations/direct\n            r\"(?P<power_automate>/powerautomate/automations/direct)?\"\n            r\"/workflows/\"\n            r\"(?P<workflow>[A-Z0-9_-]+)\"\n            r\"/triggers/manual/paths/invoke/?\"\n            r\"(?P<params>\\?.+)$\",\n            url,\n            re.I,\n        )\n\n        if result:\n            # Determine if we're using power automate or not\n            power_automate = (\n                \"&pa=yes\"\n                if result.group(\"power_automate\")\n                else \"\"\n            )\n\n            # Construct our URL\n            return NotifyWorkflows.parse_url(\n                \"{schema}://{host}{port}/{workflow}/{params}{pa}\"\n                .format(\n                    schema=NotifyWorkflows.secure_protocol[0],\n                    host=result.group(\"host\"),\n                    port=(\n                        \"\"\n                        if not result.group(\"port\")\n                        else result.group(\"port\")\n                    ),\n                    workflow=result.group(\"workflow\"),\n                    params=result.group(\"params\"),\n                    pa=power_automate,\n                )\n            )\n        return None\n"
  },
  {
    "path": "apprise/plugins/wxpusher.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# Sign-up at https://wxpusher.zjiecode.com/\n#\n# Login and acquire your App Token\n#   - Open the backend of the application:\n#         https://wxpusher.zjiecode.com/admin/\n#   - Find the appToken menu from the left menu bar, here you can reset the\n#     appToken, please note that after resetting, the old appToken will be\n#     invalid immediately and the call interface will fail.\nfrom itertools import chain\nimport json\nimport re\n\nimport requests\n\nfrom ..common import NotifyFormat, NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..url import PrivacyMode\nfrom ..utils.parse import parse_list, validate_regex\nfrom .base import NotifyBase\n\n# Topics are always numerical\nIS_TOPIC = re.compile(r\"^\\s*(?P<topic>[1-9][0-9]{0,20})\\s*$\")\n\n# users always start with UID_\nIS_USER = re.compile(\n    r\"^\\s*(?P<full>(?P<prefix>UID_)(?P<user>[^\\s]+))\\s*$\", re.I\n)\n\n\nWXPUSHER_RESPONSE_CODES = {\n    1000: \"The request was processed successfully.\",\n    1001: \"The token provided in the request is missing.\",\n    1002: \"The token provided in the request is incorrect or expired.\",\n    1003: \"The body of the message was not provided.\",\n    1004: (\n        \"The user or topic you're trying to send the message to does not exist\"\n    ),\n    1005: \"The app or topic binding process failed.\",\n    1006: \"There was an error in sending the message.\",\n    1007: \"The message content exceeds the allowed length.\",\n    1008: (\n        \"The API call frequency is too high and the server rejected the \"\n        \"request.\"\n    ),\n    1009: (\n        \"There might be other issues that are not explicitly covered by \"\n        \"the above codes\"\n    ),\n    1010: \"The IP address making the request is not whitelisted.\",\n}\n\n\nclass WxPusherContentType:\n    \"\"\"Defines the different supported content types.\"\"\"\n\n    TEXT = 1\n    HTML = 2\n    MARKDOWN = 3\n\n\nclass SubscriptionType:\n    # Verify Subscription Time\n    UNVERIFIED = 0\n    PAID_USERS = 1\n    UNSUBSCRIBED = 2\n\n\nclass NotifyWxPusher(NotifyBase):\n    \"\"\"A wrapper for WxPusher Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"WxPusher\"\n\n    # The services URL\n    service_url = \"https://wxpusher.zjiecode.com/\"\n\n    # The default protocol\n    secure_protocol = \"wxpusher\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/wxpusher/\"\n\n    # WxPusher notification endpoint\n    notify_url = \"https://wxpusher.zjiecode.com/api/send/message\"\n\n    # Define object templates\n    templates = (\"{schema}://{token}/{targets}\",)\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"token\": {\n                \"name\": _(\"App Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^AT_[^\\s]+$\", \"i\"),\n                \"private\": True,\n            },\n            \"target_topic\": {\n                \"name\": _(\"Target Topic\"),\n                \"type\": \"int\",\n                \"map_to\": \"targets\",\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User ID\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^UID_[^\\s]+$\", \"i\"),\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n                \"required\": True,\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n        },\n    )\n\n    # Used for mapping the content type to our output since Apprise supports\n    # The same formats that WxPusher does.\n    __content_type_map = {\n        NotifyFormat.MARKDOWN: WxPusherContentType.MARKDOWN,\n        NotifyFormat.TEXT: WxPusherContentType.TEXT,\n        NotifyFormat.HTML: WxPusherContentType.HTML,\n    }\n\n    def __init__(self, token, targets=None, **kwargs):\n        \"\"\"Initialize WxPusher Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # App Token (associated with WxPusher account)\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"An invalid WxPusher App Token ({token}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        # Used for URL generation afterwards only\n        self._invalid_targets = []\n\n        # For storing what is detected\n        self._users = []\n        self._topics = []\n\n        # Parse our targets\n        for target in parse_list(targets):\n            # Validate targets and drop bad ones:\n            result = IS_USER.match(target)\n            if result:\n                # store valid user\n                self._users.append(result[\"full\"])\n                continue\n\n            result = IS_TOPIC.match(target)\n            if result:\n                # store valid topic\n                self._topics.append(int(result[\"topic\"]))\n                continue\n\n            self.logger.warning(\n                f\"Dropped invalid WxPusher user/topic ({target}) specified.\",\n            )\n            self._invalid_targets.append(target)\n\n        return\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform WxPusher Notification.\"\"\"\n\n        if not self._users and not self._topics:\n            # There were no services to notify\n            self.logger.warning(\"There were no WxPusher targets to notify\")\n            return False\n\n        # Prepare our headers\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/json\",\n            \"Accept\": \"application/json\",\n        }\n\n        # Prepare our payload\n        payload = {\n            \"appToken\": self.token,\n            \"content\": body,\n            \"summary\": title,\n            \"contentType\": self.__content_type_map[self.notify_format],\n            \"topicIds\": self._topics,\n            \"uids\": self._users,\n            # unsupported at this time\n            # 'verifyPay': False,\n            # 'verifyPayType': 0,\n            \"url\": None,\n        }\n\n        # Some Debug Logging\n        self.logger.debug(\n            f\"WxPusher POST URL: {self.notify_url} \"\n            f\"(cert_verify={self.verify_certificate})\"\n        )\n        self.logger.debug(f\"WxPusher Payload: {payload}\")\n\n        # Always call throttle before any remote server i/o is made\n        self.throttle()\n\n        try:\n            r = requests.post(\n                self.notify_url,\n                data=json.dumps(payload).encode(\"utf-8\"),\n                headers=headers,\n                verify=self.verify_certificate,\n                timeout=self.request_timeout,\n            )\n\n            try:\n                content = json.loads(r.content)\n\n            except (AttributeError, TypeError, ValueError):\n                # ValueError = r.content is Unparsable\n                # TypeError = r.content is None\n                # AttributeError = r is None\n                content = {}\n\n            # 1000 is the expected return code for a successful query\n            if (\n                r.status_code == requests.codes.ok\n                and content\n                and content.get(\"code\") == 1000\n            ):\n\n                # We're good!\n                self.logger.info(\n                    \"Sent WxPusher notification to %d targets.\",\n                    len(self._users) + len(self._topics),\n                )\n\n            else:\n                error_str = (\n                    content.get(\"msg\")\n                    if content\n                    else (\n                        WXPUSHER_RESPONSE_CODES.get(\n                            content.get(\"code\") if content else None,\n                            \"An unknown error occured.\",\n                        )\n                    )\n                )\n\n                # We had a problem\n                status_str = (\n                    error_str\n                    if error_str\n                    else NotifyWxPusher.http_response_code_lookup(\n                        r.status_code\n                    )\n                )\n\n                self.logger.warning(\n                    \"Failed to send WxPusher notification, \"\n                    \"code={}/{}: {}\".format(\n                        r.status_code,\n                        \"unk\" if not content else content.get(\"code\"),\n                        status_str,\n                    )\n                )\n\n                self.logger.debug(\n                    \"Response Details:\\r\\n%r\",\n                    content if content else (r.content or b\"\")[:2000])\n\n                # Mark our failure\n                return False\n\n        except requests.RequestException as e:\n            self.logger.warning(\n                \"A Connection error occurred sending WxPusher notification.\"\n            )\n            self.logger.debug(f\"Socket Exception: {e!s}\")\n\n            return False\n\n        return True\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (self.secure_protocol, self.token)\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Define any URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        return \"{schema}://{token}/{targets}/?{params}\".format(\n            schema=self.secure_protocol,\n            token=self.pprint(\n                self.token, privacy, mode=PrivacyMode.Secret, safe=\"\"\n            ),\n            targets=\"/\".join(\n                chain(\n                    [str(t) for t in self._topics],\n                    self._users,\n                    [\n                        NotifyWxPusher.quote(x, safe=\"\")\n                        for x in self._invalid_targets\n                    ],\n                )\n            ),\n            params=NotifyWxPusher.urlencode(params),\n        )\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # Get our entries; split_path() looks after unquoting content for us\n        # by default\n        results[\"targets\"] = NotifyWxPusher.split_path(results[\"fullpath\"])\n\n        # App Token\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Extract the App token from an argument\n            results[\"token\"] = NotifyWxPusher.unquote(results[\"qsd\"][\"token\"])\n            # Any host entry defined is actually part of the path\n            # store it's element (if defined)\n            if results[\"host\"]:\n                results[\"targets\"].append(\n                    NotifyWxPusher.split_path(results[\"host\"])\n                )\n\n        else:\n            # The hostname is our source number\n            results[\"token\"] = NotifyWxPusher.unquote(results[\"host\"])\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += NotifyWxPusher.parse_list(\n                results[\"qsd\"][\"to\"]\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/xmpp/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"XMPP Notifications.\"\"\"\n\nfrom .base import NotifyXMPP\n\n__all__ = [\n    \"NotifyXMPP\",\n]\n"
  },
  {
    "path": "apprise/plugins/xmpp/adapter.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"A minimal, self-contained Slixmpp adapter.\n\nThis module provides a wrapper to Slixmpp for Apprise.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport contextlib\nimport logging\nimport re\nimport ssl\nimport threading\nimport time\nfrom typing import Any, Callable, Optional\n\nimport certifi\n\nfrom ...compat import dataclass_compat as dataclass\nfrom .common import SECURE_MODES, SecureXMPPMode\n\n# Default our global support flag\nSLIXMPP_SUPPORT_AVAILABLE = False\n\ntry:\n    import asyncio\n    from concurrent.futures import TimeoutError as FuturesTimeoutError\n\n    import slixmpp\n\n    SLIXMPP_SUPPORT_AVAILABLE = True\n\nexcept ImportError:\n    # Slixmpp is not available if code reaches here\n    slixmpp = None  # type: ignore[assignment]\n    asyncio = None  # type: ignore[assignment]\n    FuturesTimeoutError = Exception  # type: ignore[misc]\n\n\n@dataclass(frozen=True, slots=True)\nclass XMPPConfig:\n    \"\"\"Connection configuration.\"\"\"\n\n    host: str\n    port: int\n    jid: str\n    password: str\n    secure: str = SecureXMPPMode.STARTTLS\n    verify_certificate: bool = True\n\n\n# ---------------------------------------------------------------------------\n# Logging Bridge\n# ---------------------------------------------------------------------------\nLOGGING_ID = \"apprise.xmpp\"\n_LOG_BRIDGE_LOCK = threading.Lock()\n_LOG_BRIDGED = False\n\n\ndef bridge_slixmpp_logging() -> None:\n    \"\"\"Bridge Slixmpp logging into Apprise logging handlers.\n\n    This is intentionally idempotent to prevent handler duplication when many\n    notifications are sent within the same process.\n    \"\"\"\n    global _LOG_BRIDGED\n\n    if _LOG_BRIDGED:\n        return\n\n    with _LOG_BRIDGE_LOCK:\n        if _LOG_BRIDGED:\n            return\n\n        apprise_logger = logging.getLogger(\"apprise\")\n        slix_logger = logging.getLogger(\"slixmpp\")\n\n        existing = {id(h) for h in slix_logger.handlers}\n        for handler in apprise_logger.handlers:\n            if id(handler) not in existing:\n                slix_logger.addHandler(handler)\n                existing.add(id(handler))\n\n        slix_logger.setLevel(apprise_logger.getEffectiveLevel())\n\n        # Prevent duplicates via propagation chains.\n        slix_logger.propagate = False\n\n        _LOG_BRIDGED = True\n\n\ndef _close_awaitable(obj: Any) -> None:\n    \"\"\"Best-effort close for coroutine-like objects.\n\n    Some test patches raise before awaiting, leaving coroutines to be\n    garbage collected and triggering runtime warnings.\n    \"\"\"\n    close = getattr(obj, \"close\", None)\n    if callable(close):\n        with contextlib.suppress(Exception):\n            close()\n\n\n# ---------------------------------------------------------------------------\n# Internal Slixmpp Client Factory\n# ---------------------------------------------------------------------------\n\n\n_CLIENT_SUBCLASS_CACHE: dict[int, type[Any]] = {}\n\n\ndef _get_client_subclass(base_cls: type[Any]) -> type[Any]:\n    \"\"\"Return (and cache) the internal client subclass for a given base class.\n\n    The tests monkeypatch `xmpp_adapter.slixmpp.ClientXMPP`, so we must resolve\n    the base class dynamically at runtime, not at import time. We still cache\n    the derived subclass per base class identity to avoid repeated class\n    creation overhead in production.\n    \"\"\"\n    key = id(base_cls)\n    cached = _CLIENT_SUBCLASS_CACHE.get(key)\n    if cached is not None:\n        return cached\n\n    class _Client(base_cls):  # type: ignore[misc]\n        \"\"\"Internal Slixmpp client for both one-shot and keepalive flows.\"\"\"\n\n        def __init__(\n            self,\n            jid: str,\n            password: str,\n            *,\n            oneshot: bool,\n            targets: Optional[list[str]] = None,\n            subject: str = \"\",\n            body: str = \"\",\n            before_message: Optional[Callable[[], None]] = None,\n            want_roster: bool = False,\n            roster_timeout: float = 0.0,\n            session_started_evt: Optional[asyncio.Event] = None,\n            # type: ignore[name-defined]\n        ) -> None:\n            super().__init__(jid, password)\n\n            # Behaviour\n            self._oneshot = bool(oneshot)\n\n            # Send payload (only used in oneshot mode)\n            self._targets = list(targets or [])\n            self._subject = subject\n            self._body = body\n            self._before_message = before_message\n\n            # Roster behaviour (both modes)\n            self._want_roster = bool(want_roster)\n            self._roster_timeout = float(roster_timeout)\n\n            # Keepalive coordination (keepalive mode only)\n            self._session_started_evt = session_started_evt\n\n            # State\n            self._auth_failed = False\n\n            self.add_event_handler(\"session_start\", self._on_session_start)\n            self.add_event_handler(\"failed_auth\", self._failed_auth)\n            self.add_event_handler(\"disconnected\", self._disconnected)\n\n            # Keep behaviour predictable and close quickly.\n            self.auto_reconnect = False\n\n        async def _session_start(\n            self, *args: object, **kwargs: object\n        ) -> None:\n            try:\n                with contextlib.suppress(Exception):\n                    self.send_presence()\n\n                if self._want_roster and self._roster_timeout > 0:\n                    roster_coro = self.get_roster()\n                    try:\n                        await asyncio.wait_for(\n                            roster_coro,\n                            timeout=self._roster_timeout,\n                        )\n                    except Exception:\n                        _close_awaitable(roster_coro)\n\n                # One-shot mode sends messages immediately on session_start and\n                # then disconnects. Keepalive mode just signals readiness.\n                if self._oneshot:\n                    for target in self._targets:\n                        if self._before_message:\n                            self._before_message()\n\n                        self.send_message(\n                            mto=target,\n                            msubject=self._subject,\n                            mbody=self._body,\n                            mtype=\"chat\",\n                        )\n\n            finally:\n                if self._oneshot:\n                    self.disconnect()\n\n                elif self._session_started_evt is not None:\n                    self._session_started_evt.set()\n\n        def _failed_auth(self, *args: object, **kwargs: object) -> None:\n            # Authentication failure is always a hard failure.\n            self._auth_failed = True\n            if self._session_started_evt is not None:\n                self._session_started_evt.clear()\n            self.disconnect()\n\n        def _on_session_start(self, *args: object, **kwargs: object) -> Any:\n            \"\"\"Slixmpp event handler entrypoint.\n\n            Must be synchronous. Also, we must never fall back to\n            asyncio.create_task() because that can bind to the wrong\n            loop, or no loop, and leak coroutines.\n            \"\"\"\n            coro = self._session_start(*args, **kwargs)\n\n            # Schedule on the event loop for both one-shot and keepalive.\n            loop = getattr(self, \"loop\", None)\n\n            # If the loop is missing or already closing, we MUST close the\n            # coroutine immediately to prevent \"never awaited\" warnings.\n            if loop is None or not loop.is_running():\n                with contextlib.suppress(Exception):\n                    coro.close()\n                return None\n\n            try:\n                task = loop.create_task(coro)\n\n                def _log_task(t: asyncio.Task[Any]) -> None:\n                    if t.cancelled():\n                        return\n\n                    exc = t.exception()\n                    if exc is not None:\n                        self.logger.error(\"XMPP task failed: %s\", exc)\n\n                task.add_done_callback(_log_task)\n\n            except Exception:\n                # Fallback closure if loop.create_task fails\n                with contextlib.suppress(Exception):\n                    coro.close()\n\n            return None\n\n        def _disconnected(self, *args: object, **kwargs: object) -> None:\n            if self._session_started_evt is not None:\n                self._session_started_evt.clear()\n\n    _CLIENT_SUBCLASS_CACHE[key] = _Client\n    return _Client\n\n\ndef _build_client(*args: Any, **kwargs: Any) -> Any:\n    return _get_client_subclass(slixmpp.ClientXMPP)(*args, **kwargs)\n\n# ---------------------------------------------------------------------------\n# Adapter\n# ---------------------------------------------------------------------------\n\n\nclass SlixmppAdapter:\n    \"\"\"Send a message to one or more targets.\n\n    When keepalive is False, process() performs a one-shot connect, send,\n    disconnect.\n\n    When keepalive is True, send_message() keeps a session alive across calls.\n    The connection is closed only when close() is called or the instance is\n    garbage collected.\n    \"\"\"\n\n    # Define a Slixmpp reference version to prevent this tool from working\n    # under non-supported versions\n    _supported_version = (1, 10, 0)\n\n    # Flag to control if we are enabled or not\n    # effectively.. .is the dependent slixmpp library available to us\n    # or not\n    _enabled = SLIXMPP_SUPPORT_AVAILABLE\n\n    def __init__(\n        self,\n        config: XMPPConfig,\n        targets: list[str],\n        subject: str,\n        body: str,\n        timeout: float = 30.0,\n        roster: bool = False,\n        before_message: Optional[Callable[[], None]] = None,\n        keepalive: bool = False,\n    ) -> None:\n        self.config, self.targets, self.subject, self.body = \\\n            config, targets, subject, body\n\n        self.timeout = max(5.0, float(timeout))\n        self.roster, self.before_message, self.keepalive = \\\n            roster, before_message, keepalive\n\n        global LOGGING_ID\n        self.logger = logging.getLogger(LOGGING_ID)\n\n        bridge_slixmpp_logging()\n\n        # Keepalive internals (only used when keepalive=True)\n        self._state_lock = threading.RLock()\n        self._closing = False\n        self._thread: Optional[threading.Thread] = None\n        self._loop: Optional[asyncio.AbstractEventLoop] = None\n        # type: ignore[name-defined]\n        self._client: Optional[slixmpp.ClientXMPP] = None\n        # type: ignore[name-defined]\n        self._loop_ready = threading.Event()\n\n        # asyncio primitives created inside the loop thread\n        self._connect_lock: Optional[asyncio.Lock] = None\n        # type: ignore[name-defined]\n        self._session_started: Optional[asyncio.Event] = None\n        # type: ignore[name-defined]\n\n    def __del__(self) -> None:\n        \"\"\"Best effort close for keepalive sessions.\"\"\"\n        with contextlib.suppress(Exception):\n            self.close()\n\n    @staticmethod\n    def _ssl_context(verify: bool) -> ssl.SSLContext:\n        ctx = ssl.create_default_context(cafile=certifi.where())\n        if not verify:\n            ctx.check_hostname = False\n            ctx.verify_mode = ssl.CERT_NONE\n        return ctx\n\n    @staticmethod\n    def _loop_tick(loop: asyncio.AbstractEventLoop) -> None:\n        \"\"\"Run one final loop tick, closing the coroutine on error.\"\"\"\n        tick = asyncio.sleep(0)\n        try:\n            loop.run_until_complete(tick)\n\n        except Exception:\n            _close_awaitable(tick)\n\n    @staticmethod\n    def _finalize_loop(loop: asyncio.AbstractEventLoop) -> None:\n        \"\"\"Best-effort loop shutdown to avoid resource warnings.\"\"\"\n\n        with contextlib.suppress(Exception):\n            # Cancel any pending tasks\n            tasks = asyncio.all_tasks(loop)\n            for task in tasks:\n                task.cancel()\n\n            if tasks:\n                loop.run_until_complete(\n                    asyncio.gather(*tasks, return_exceptions=True)\n                )\n\n        # Give the loop one final tick to process cancellations\n        SlixmppAdapter._loop_tick(loop)\n\n        with contextlib.suppress(Exception):\n            loop.stop()\n\n        # Only attempt to shutdown generators if the loop is still open.\n        # Otherwise, creating the coroutine without running it triggers a\n        # RuntimeWarning.\n        if not loop.is_closed():\n            with contextlib.suppress(Exception):\n                ag_coro = loop.shutdown_asyncgens()\n                try:\n                    loop.run_until_complete(ag_coro)\n                except Exception:\n                    _close_awaitable(ag_coro)\n\n        # Detach loop from thread policy\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        if not loop.is_closed():\n            with contextlib.suppress(Exception):\n                loop.close()\n\n    def close(self) -> None:\n        \"\"\"Close any persistent connection and stop the keepalive worker.\"\"\"\n        with self._state_lock:\n            self._closing = True\n\n        loop, client, thread = self._loop, self._client, self._thread\n        if loop is None or thread is None:\n            return\n\n        def _shutdown() -> None:\n            try:\n                if client is not None:\n                    client.disconnect()\n            finally:\n                with contextlib.suppress(Exception):\n                    loop.stop()\n\n        with contextlib.suppress(Exception):\n            loop.call_soon_threadsafe(_shutdown)\n\n        # Give a moment to exit gracefully.\n        thread.join(max(1.0, min(5.0, self.timeout / 2.0)))\n\n        # If the worker is still alive, do not clear state.\n        alive = getattr(thread, \"is_alive\", None)\n        if callable(alive) and alive():\n            return\n\n        with self._state_lock:\n            # Detach from any thread-local loop to avoid creating a new\n            # loop implicitly (Python 3.12+ may warn about it).\n            with contextlib.suppress(Exception):\n                asyncio.set_event_loop(None)\n\n            self._thread = None\n            self._loop = None\n            self._client = None\n            self._connect_lock = None\n            self._session_started = None\n\n    # -----------------------------------------------------------------------\n    # One-shot behaviour (no keepalive)\n    # -----------------------------------------------------------------------\n\n    def process(self) -> bool:\n        \"\"\"Send the message, always returning within timeout.\"\"\"\n        done = threading.Event()\n        result: list[Optional[bool]] = [None]\n\n        if not self._enabled:\n            # We are not turned on\n            return False\n\n        shared: dict[str, Any] = {\"loop\": None, \"client\": None}\n\n        def runner() -> None:\n            loop: Optional[asyncio.AbstractEventLoop] = None\n            # type: ignore[name-defined]\n            start = time.monotonic()\n\n            try:\n                loop = asyncio.new_event_loop()  # type: ignore[union-attr]\n                asyncio.set_event_loop(loop)  # type: ignore[union-attr]\n                shared[\"loop\"] = loop\n\n                targets = (\n                    list(self.targets) if self.targets else [self.config.jid])\n\n                roster_timeout = (\n                    max(2.0, min(10.0, self.timeout / 3.0))\n                    if self.roster else 0.0\n                )\n\n                client = _build_client(\n                    jid=self.config.jid,\n                    password=self.config.password,\n                    oneshot=True,\n                    targets=targets,\n                    subject=self.subject,\n                    body=self.body,\n                    before_message=self.before_message,\n                    want_roster=self.roster,\n                    roster_timeout=roster_timeout,\n                    session_started_evt=None,\n                )\n\n                shared[\"client\"] = client\n\n                # Prevent Slixmpp from owning loop lifecycle\n                with contextlib.suppress(Exception):\n                    client.loop = loop  # type: ignore[assignment]\n\n                # Resolve connection behaviour from secure mode\n                mode_cfg = SECURE_MODES.get(self.config.secure)\n                if not mode_cfg:\n                    raise ValueError(\n                        f\"Unsupported XMPP secure mode: {self.config.secure}\"\n                    )\n\n                client.enable_plaintext = bool(mode_cfg[\"enable_plaintext\"])\n                client.enable_starttls = bool(mode_cfg[\"enable_starttls\"])\n                client.enable_direct_tls = bool(mode_cfg[\"enable_direct_tls\"])\n\n                # Only attach an SSL context when TLS may be used\n                if not client.enable_plaintext:\n                    client.ssl_context = self._ssl_context(\n                        self.config.verify_certificate\n                    )\n\n                # Slixmpp >= 1.10.0 connect() returns a Future.\n                connect_timeout = max(3.0, min(15.0, self.timeout / 3.0))\n                connect_fut = client.connect(\n                    host=self.config.host,\n                    port=self.config.port,\n                )\n\n                try:\n                    ok = loop.run_until_complete(\n                        asyncio.wait_for(\n                            connect_fut,\n                            timeout=connect_timeout,\n                        )\n                    )\n                    if ok is False:\n                        self.logger.warning(\"XMPP connect failed.\")\n                        with contextlib.suppress(Exception):\n                            client.disconnect()\n                        result[0] = False\n                        return\n\n                except asyncio.TimeoutError:\n                    self.logger.warning(\n                        \"XMPP connect timed out after %.2fs\", connect_timeout\n                    )\n                    result[0] = False\n                    return\n\n                except Exception as e:\n                    self.logger.debug(\"XMPP connect failed: %s\", e)\n                    result[0] = False\n                    return\n\n                # Run until disconnected, but still respect our overall\n                # timeout.\n                elapsed = time.monotonic() - start\n                remaining = max(0.0, self.timeout - elapsed)\n                run_timeout = max(1.0, remaining)\n\n                try:\n                    loop.run_until_complete(\n                        asyncio.wait_for(  # type: ignore[arg-type]\n                            client.disconnected, timeout=run_timeout\n                        )\n                    )\n                except asyncio.TimeoutError:  # type: ignore[attr-defined]\n                    self.logger.warning(\n                        \"XMPP session timed out after %.2fs\", run_timeout\n                    )\n                    with contextlib.suppress(Exception):\n                        client.disconnect()\n                    result[0] = False\n                    return\n\n                # Disconnect happened, success depends on auth state\n                result[0] = not bool(getattr(client, \"_auth_failed\", False))\n\n            except Exception as e:  # pragma: no cover\n                self.logger.warning(\"XMPP send failed.\")\n                self.logger.debug(\"XMPP Exception: %s\", e)\n                result[0] = False\n\n            finally:\n                loop = shared.get(\"loop\")\n                if loop is not None:\n                    self._finalize_loop(loop)\n                done.set()\n\n        t = threading.Thread(target=runner, name=\"apprise-xmpp\", daemon=True)\n        t.start()\n\n        if not done.wait(timeout=self.timeout):\n            self.logger.warning(\n                \"XMPP send timed out after %.2fs.\", self.timeout)\n            result[0] = False\n\n            loop_obj = shared.get(\"loop\")\n            client_obj = shared.get(\"client\")\n\n            if loop_obj is not None:\n                loop = loop_obj  # type: ignore[assignment]\n                try:\n                    if client_obj is not None:\n                        client = client_obj  # type: ignore[assignment]\n                        loop.call_soon_threadsafe(client.disconnect)\n                except Exception:\n                    pass\n\n                with contextlib.suppress(Exception):\n                    loop.call_soon_threadsafe(loop.stop)\n\n            t.join(timeout=0.25)\n\n        return bool(result[0])\n\n    # -----------------------------------------------------------------------\n    # Keepalive Behaviour\n    # -----------------------------------------------------------------------\n\n    def _ensure_keepalive_worker(self) -> bool:\n        \"\"\"Ensure the background loop and client exist.\"\"\"\n        if not self.keepalive:\n            return False\n\n        with self._state_lock:\n            if self._closing:\n                return False\n\n            if self._thread is not None and self._thread.is_alive():\n                return True\n\n            if not self._enabled:\n                return False\n\n            self._loop_ready.clear()\n\n            self._thread = threading.Thread(\n                target=self._keepalive_runner,\n                name=\"apprise-xmpp-keepalive\",\n                daemon=True,\n            )\n            self._thread.start()\n\n        if not self._loop_ready.wait(timeout=self.timeout):\n            self.logger.warning(\n                \"XMPP keepalive worker failed to start within %.2fs\",\n                self.timeout,\n            )\n            return False\n\n        return True\n\n    def _keepalive_runner(self) -> None:\n        loop: Optional[asyncio.AbstractEventLoop] = None\n        # type: ignore[name-defined]\n        published = False\n\n        try:\n            loop = asyncio.new_event_loop()  # type: ignore[union-attr]\n            asyncio.set_event_loop(loop)  # type: ignore[union-attr]\n\n            session_started = asyncio.Event()  # type: ignore[union-attr]\n            connect_lock = asyncio.Lock()  # type: ignore[union-attr]\n\n            roster_timeout = (\n                max(2.0, min(10.0, self.timeout / 3.0))\n                if self.roster else 0.0\n            )\n\n            client = _build_client(\n                jid=self.config.jid,\n                password=self.config.password,\n                oneshot=False,\n                want_roster=self.roster,\n                roster_timeout=roster_timeout,\n                session_started_evt=session_started,\n            )\n\n            with contextlib.suppress(Exception):\n                client.loop = loop  # type: ignore[assignment]\n\n            mode_cfg = SECURE_MODES.get(self.config.secure)\n            if not mode_cfg:\n                raise ValueError(\n                    f\"Unsupported XMPP secure mode: {self.config.secure}\"\n                )\n\n            client.enable_plaintext = bool(mode_cfg[\"enable_plaintext\"])\n            client.enable_starttls = bool(mode_cfg[\"enable_starttls\"])\n            client.enable_direct_tls = bool(mode_cfg[\"enable_direct_tls\"])\n\n            if not client.enable_plaintext:\n                client.ssl_context = self._ssl_context(\n                    self.config.verify_certificate\n                )\n\n            # keepalive=yes implies enabling XEP-0199 keepalive pings\n            with contextlib.suppress(Exception):\n                client.register_plugin(\"xep_0199\", {\"keepalive\": True})\n\n            with self._state_lock:\n                if self._closing:\n                    return\n\n                self._loop = loop\n                self._client = client\n                self._connect_lock = connect_lock\n                self._session_started = session_started\n                published = True\n\n            self._loop_ready.set()\n\n            loop.run_forever()\n\n        except Exception as e:  # pragma: no cover\n            self.logger.warning(\"XMPP keepalive worker failed.\")\n            self.logger.debug(\"XMPP keepalive exception: %s\", e)\n\n        finally:\n            if published:\n                with self._state_lock:\n                    if self._closing and self._loop is loop:\n                        # Clear internal references if we are exiting the\n                        # worker.\n                        self._loop = None\n                        self._client = None\n                        self._connect_lock = None\n                        self._session_started = None\n                        self._thread = None\n\n            if loop is not None:\n                self._finalize_loop(loop)\n\n    async def _connect_if_required(self) -> bool:\n        if self._loop is None or self._client is None:\n            return False\n        if self._connect_lock is None or self._session_started is None:\n            return False\n\n        # If auth already failed, do not pretend a connection is ready.\n        if bool(getattr(self._client, \"_auth_failed\", False)):\n            return False\n\n        async with self._connect_lock:\n            if self._session_started.is_set():\n                return True\n\n            connect_timeout = max(3.0, min(15.0, self.timeout / 3.0))\n\n            try:\n                fut = self._client.connect(\n                    host=self.config.host,\n                    port=self.config.port,\n                )\n                connect_ok = await asyncio.wait_for(  # type: ignore[arg-type]\n                    fut, timeout=connect_timeout\n                )\n\n                # honour boolean connect() result in keepalive.\n                if not connect_ok:\n                    self.logger.warning(\"XMPP connect failed.\")\n                    with contextlib.suppress(Exception):\n                        self._client.disconnect()\n                    return False\n\n            except asyncio.TimeoutError:  # type: ignore[attr-defined]\n                self.logger.warning(\n                    \"XMPP connect timed out after %.2fs\", connect_timeout\n                )\n                return False\n\n            except Exception as e:\n                self.logger.debug(\"XMPP connect failed: %s\", e)\n                return False\n\n            try:\n                session_wait = self._session_started.wait()\n                await asyncio.wait_for(\n                    session_wait,\n                    timeout=connect_timeout,\n                )\n            except asyncio.TimeoutError:  # type: ignore[attr-defined]\n                _close_awaitable(session_wait)\n                self.logger.warning(\n                    \"XMPP session did not start within %.2fs\",\n                    connect_timeout,\n                )\n                return False\n\n            except Exception:\n                _close_awaitable(session_wait)\n\n                return False\n\n            # If auth failed during startup, treat as failure.\n            return not bool(getattr(self._client, \"_auth_failed\", False))\n\n    async def _send_keepalive_async(\n        self,\n        targets: list[str],\n        subject: str,\n        body: str,\n    ) -> bool:\n        if self._client is None:\n            return False\n\n        ok = await self._connect_if_required()\n        if ok is False:\n            return False\n\n        # Auth failed after connect, do not send.\n        if bool(getattr(self._client, \"_auth_failed\", False)):\n            return False\n\n        send_targets = targets if targets else [self.config.jid]\n\n        try:\n            for target in send_targets:\n                self._client.send_message(\n                    mto=target,\n                    msubject=subject,\n                    mbody=body,\n                    mtype=\"chat\",\n                )\n            return True\n\n        except Exception as e:\n            self.logger.debug(\"XMPP send failed: %s\", e)\n            if self._session_started is not None:\n                self._session_started.clear()\n            return False\n\n    def send_message(\n        self,\n        targets: Optional[list[str]] = None,\n        subject: Optional[str] = None,\n        body: Optional[str] = None,\n    ) -> bool:\n        \"\"\"Send a message, keeping the session alive if keepalive=True.\"\"\"\n\n        if not self.keepalive:\n            # Fallback to one-shot behaviour using current stored attributes\n            if targets is not None:\n                self.targets = targets\n            if subject is not None:\n                self.subject = subject\n            if body is not None:\n                self.body = body\n            return self.process()\n\n        if not self._ensure_keepalive_worker():\n            return False\n\n        loop = self._loop\n        if loop is None:\n            return False\n\n        targets = self.targets if targets is None else targets\n        subject = self.subject if subject is None else subject\n        body = self.body if body is None else body\n\n        coro = self._send_keepalive_async(\n            targets=targets,\n            subject=subject,\n            body=body,\n        )\n\n        try:\n            fut = asyncio.run_coroutine_threadsafe(  # type: ignore[union-attr]\n                coro,\n                loop,\n            )\n            return bool(fut.result(timeout=self.timeout))\n\n        except FuturesTimeoutError:\n            self.logger.warning(\n                \"XMPP keepalive send timed out after %.2fs\", self.timeout\n            )\n            if self._session_started is not None:\n                with contextlib.suppress(Exception):\n                    loop.call_soon_threadsafe(self._session_started.clear)\n            return False\n\n        except Exception as e:\n            with contextlib.suppress(Exception):\n                coro.close()\n            self.logger.debug(\"XMPP keepalive send exception: %s\", e)\n            return False\n\n    @staticmethod\n    def package_dependency() -> str:\n        \"\"\"Defines our static dependency for this adapter to work.\"\"\"\n        version = \".\".join([str(v) for v in SlixmppAdapter._supported_version])\n        return f\"slixmpp >= {version}\"\n\n    @staticmethod\n    def supported_version(version: Optional[str] = None) -> bool:\n        \"\"\"Returns true if we currently have a version of Slixmpp supported.\n\n        Provided string describes a version in format of major.minor.patch.\n        \"\"\"\n        if SLIXMPP_SUPPORT_AVAILABLE:\n            m = re.match(\n                r\"^(?P<major>\\d+)(\\.(?P<minor>\\d+)(\\.(?P<patch>\\d+))?)?\",\n                version or getattr(slixmpp, \"__version__\", \"\") or \"\",\n            )\n            if not m:\n                return False\n\n            return (\n                int(m.group(\"major\")),\n                int(m.group(\"minor\") or 0),\n                int(m.group(\"patch\") or 0),\n            ) >= SlixmppAdapter._supported_version\n\n        return False\n"
  },
  {
    "path": "apprise/plugins/xmpp/base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"XMPP Notifications\"\"\"\n\nfrom __future__ import annotations\n\nimport re\nfrom typing import Any, Optional\n\nfrom ...common import NotifyType\nfrom ...locale import gettext_lazy as _\nfrom ...url import PrivacyMode\nfrom ...utils.parse import parse_bool, parse_list\nfrom ..base import NotifyBase\nfrom .adapter import SLIXMPP_SUPPORT_AVAILABLE, SlixmppAdapter, XMPPConfig\nfrom .common import SECURE_MODES, SecureXMPPMode\n\n# A pragmatic, \"hardened\" JID validator intended for Apprise URLs.\n#\n# - Supports: local@domain and local@domain/resource\n# - Rejects whitespace anywhere\n# - Rejects missing local or domain\n# - Rejects '@' in the domain component\n#\n# This does not try to fully implement RFC 7622. The goal is to catch bad\n# inputs early and reliably while still supporting common JID patterns.\nIS_JID = re.compile(\n    r\"^\\s*(?P<local>[^@\\s/]+)((@|%40)\"\n    r\"(?P<domain>[^@\\s/]+))?(?:(/|%2F)(?P<resource>[^%/\\s]+)\"\n    r\"((/|%2F).*)?)?\\s*$\"\n)\n\n\nclass NotifyXMPP(NotifyBase):\n    \"\"\"Send notifications via XMPP using Slixmpp.\"\"\"\n\n    # Set our global enabled flag\n    enabled = SLIXMPP_SUPPORT_AVAILABLE and SlixmppAdapter._enabled\n\n    requirements = {\n        # Define our required packaging in order to work\n        \"packages_required\": SlixmppAdapter.package_dependency(),\n    }\n\n    # The default descriptive name associated with the Notification\n    service_name = \"XMPP\"\n\n    # The services URL\n    service_url = \"https://xmpp.org/\"\n\n    # The default insecure protocol\n    protocol = \"xmpp\"\n\n    # The default secure protocol\n    secure_protocol = \"xmpps\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/xmpp/\"\n\n    templates = (\n        \"{schema}://{user}:{password}@{host}\",\n        \"{schema}://{user}:{password}@{host}:{port}\",\n        \"{schema}://{user}:{password}@{host}/{targets}\",\n        \"{schema}://{user}:{password}@{host}:{port}/{targets}\",\n    )\n\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"host\": {\n                \"name\": _(\"Hostname\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"port\": {\n                \"name\": _(\"Port\"),\n                \"type\": \"int\",\n                \"min\": 1,\n                \"max\": 65535,\n            },\n            \"user\": {\n                \"name\": _(\"User\"),\n                \"type\": \"string\",\n                \"required\": True,\n            },\n            \"password\": {\n                \"name\": _(\"Password\"),\n                \"type\": \"string\",\n                \"private\": True,\n                \"required\": True,\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"mode\": {\n                \"name\": _(\"Secure Mode\"),\n                \"type\": \"choice:string\",\n                \"values\": SECURE_MODES,\n                \"default\": SecureXMPPMode.STARTTLS,\n                \"map_to\": \"secure_mode\",\n            },\n            \"roster\": {\n                \"name\": _(\"Get Roster\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"subject\": {\n                \"name\": _(\"Use Subject\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"keepalive\": {\n                \"name\": _(\"Keep Connection Alive\"),\n                \"type\": \"bool\",\n                \"default\": False,\n            },\n            \"to\": {\"alias_of\": \"targets\"},\n        },\n    )\n\n    def __init__(\n        self,\n        targets: Optional[list[str]] = None,\n        secure_mode: Optional[str] = None,\n        roster: Optional[bool] = None,\n        subject: Optional[bool] = None,\n        keepalive: Optional[bool] = None,\n        **kwargs: Any,\n    ) -> None:\n        super().__init__(**kwargs)\n\n        try:\n            self.jid = self.normalize_jid(self.user or \"\", self.host)\n\n        except ValueError:\n            msg = f\"An invalid XMPP JID ({self.user}) was specified.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from None\n\n        self.targets: list[str] = []\n        for target in parse_list(targets):\n            try:\n                jid = self.normalize_jid(target or \"\", self.host)\n\n            except ValueError:\n                self.logger.warning(\n                    \"Dropped invalid XMPP target (%s).\", target)\n                continue\n            self.targets.append(jid)\n\n        if isinstance(secure_mode, str) and secure_mode.strip():\n            self.secure_mode = secure_mode.strip().lower()\n            self.secure_mode = next(\n                (k for k in SECURE_MODES\n                 if k.startswith(self.secure_mode)), None\n            )\n            if self.secure_mode not in SECURE_MODES:\n                msg = (\n                    \"The XMPP secure mode specified \"\n                    f\"({secure_mode}) is invalid.\")\n                self.logger.warning(msg)\n                raise TypeError(msg)\n\n        else:\n            self.secure_mode = (\n                SecureXMPPMode.NONE\n                if not self.secure\n                else self.template_args[\"mode\"][\"default\"]\n            )\n\n        # Prepare our roster check\n        self.roster = (\n            self.template_args[\"roster\"][\"default\"]\n            if roster is None else bool(roster)\n        )\n\n        self.subject = (\n            self.template_args[\"subject\"][\"default\"]\n            if subject is None else bool(subject)\n        )\n\n        self.keepalive = (\n            self.template_args[\"keepalive\"][\"default\"]\n            if keepalive is None\n            else bool(keepalive)\n        )\n\n        if self.secure and self.secure_mode == SecureXMPPMode.NONE:\n            self.secure_mode = self.template_args[\"mode\"][\"default\"]\n            self.logger.warning(\n                \"Ambiguous XMPP configuration: secure=True and mode=None; \"\n                \"secure setting prevails; setting mode=%s\",\n                self.secure_mode,\n            )\n\n        elif not self.secure and self.secure_mode != SecureXMPPMode.NONE:\n            self.logger.warning(\n                \"Ambiguous XMPP configuration: secure=False and mode=%s; \"\n                \"mode setting prevails; setting secure=True\",\n                self.secure_mode,\n            )\n            self.secure = True\n\n        # Keepalive adapter (created lazily)\n        self._adapter: Optional[SlixmppAdapter] = None\n\n    def __del__(self) -> None:\n        \"\"\"Best-effort close for keepalive sessions.\"\"\"\n        try:\n            if self._adapter is not None:\n                self._adapter.close()\n\n        except Exception:\n            # Never raise from __del__\n            pass\n\n    @property\n    def url_identifier(self) -> tuple[str, str, str, str, Optional[int]]:\n        \"\"\"Return the pieces that uniquely identify this configuration.\"\"\"\n        return (\n            self.secure_protocol if self.secure else self.protocol,\n            self.host, self.user, self.password, self.port,\n        )\n\n    def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str:\n        \"\"\"Return the URL representation of this notification.\"\"\"\n\n        # Initialize our parameters\n        params = {\n            \"mode\": self.secure_mode,\n            \"roster\": \"yes\" if self.roster else \"no\",\n            \"subject\": \"yes\" if self.subject else \"no\",\n            \"keepalive\": \"yes\" if self.keepalive else \"no\",\n        }\n\n        # Extend our parameters\n        params.update(self.url_parameters(privacy=privacy, *args, **kwargs))\n\n        auth = \"{user}:{password}@\".format(\n            user=self.quote(self.jid, safe=\"\"),\n            password=self.pprint(\n                self.password,\n                privacy,\n                mode=PrivacyMode.Secret,\n                safe=\"\",\n            ),\n        )\n\n        default_port = SECURE_MODES[self.secure_mode][\"default_port\"]\n        port = self.port if isinstance(self.port, int) else default_port\n        port_str = \"\" if port == default_port else f\":{port}\"\n\n        schema = self.secure_protocol if self.secure else self.protocol\n\n        # Targets can contain '/' as a resource separator, so ensure it is\n        # always percent-encoded in the path (otherwise Apprise will split it).\n        targets = \"/\".join(self.quote(t, safe=\"\") for t in self.targets)\n\n        return \"{schema}://{auth}{host}{port}/{targets}?{params}\".format(\n            schema=schema,\n            auth=auth,\n            host=self.host,\n            port=port_str,\n            targets=targets,\n            params=self.urlencode(params),\n        )\n\n    def send(\n        self,\n        body: str,\n        title: str = \"\",\n        notify_type: NotifyType = NotifyType.INFO,\n        **kwargs: Any,\n    ) -> bool:\n        \"\"\"Send a notification to one or more XMPP targets.\"\"\"\n\n        default_port = SECURE_MODES[self.secure_mode][\"default_port\"]\n\n        self.throttle()\n\n        config = XMPPConfig(\n            jid=self.jid,\n            password=self.password or \"\",\n            host=self.host,\n            port=self.port if self.port else default_port,\n            secure=self.secure_mode,\n            verify_certificate=self.verify_certificate,\n        )\n\n        self.logger.debug(\n            \"XMPP init: jid=%s host=%s port=%d mode=%s \"\n            \"verify_certificate=%s subject=%s roster=%s keepalive=%s \"\n            \"targets=%s\",\n            self.jid,\n            config.host,\n            config.port,\n            config.secure,\n            config.verify_certificate,\n            \"yes\" if self.subject else \"no\",\n            \"yes\" if self.roster else \"no\",\n            \"yes\" if self.keepalive else \"no\",\n            self.targets,\n        )\n\n        subject = title if self.subject else \"\"\n\n        if self.keepalive and self._adapter:\n            # Reuse existing adapter\n            return self._adapter.send_message(\n                targets=self.targets,\n                subject=subject,\n                body=body,\n            )\n\n        adapter_kwargs = {\n            \"config\": config,\n            \"targets\": self.targets,\n            \"subject\": subject,\n            \"body\": body,\n            \"timeout\": self.socket_connect_timeout,\n            \"roster\": self.roster,\n            \"keepalive\": self.keepalive,\n        }\n        if not self.keepalive:\n            # One-shot mode: Create, process, and discard\n            return SlixmppAdapter(**adapter_kwargs).process()\n\n        # Keepalive mode, reuse a single adapter instance\n        self._adapter = SlixmppAdapter(**adapter_kwargs)\n        return self._adapter.send_message()\n\n    @property\n    def title_maxlen(self) -> Optional[int]:\n        \"\"\"\n        Depending on if the subject field is set, we can control\n        how the message is constructed.\n        \"\"\"\n\n        return 0 if not self.subject else super().title_maxlen\n\n        # We don't support titles for SMSEagle notifications\n    @staticmethod\n    def normalize_jid(value: str, default_host: str) -> str:\n        \"\"\"Normalize and validate a JID.\n\n        Behaviour:\n        - If value is 'user' then it becomes 'user@default_host'.\n        - If value is 'user@host' then it becomes 'user@host'.\n        - If value is 'user@host/resource' then it becomes\n           'user@host/resource'.\n        - If value is 'user/resource' then it becomes\n           'user@default_host/resource'.\n        - If value already contains '@', it is used as-is, including an\n           optional '/resource' suffix.\n        \"\"\"\n        raw = (value or \"\").strip()\n        if not raw:\n            raise ValueError(\"Empty JID\")\n\n        results = IS_JID.match(raw)\n        if not results:\n            raise ValueError(\"Invalid JID\")\n\n        host = default_host \\\n            if not results.group(\"domain\") else results.group(\"domain\")\n\n        jid = f\"{results.group('local')}@{host}\"\n        if results.group(\"resource\"):\n            jid = f\"{jid}/{results.group('resource')}\"\n\n        return jid\n\n    @staticmethod\n    def parse_url(url: str) -> Optional[dict[str, Any]]:\n        \"\"\"Parse an XMPP URL into constructor arguments.\"\"\"\n        results = NotifyBase.parse_url(url)\n        if not results:\n            return None\n\n        # Targets from path\n        results[\"targets\"] = [\n            NotifyXMPP.unquote(t)\n            for t in NotifyXMPP.split_path(results.get(\"fullpath\"))]\n\n        qd = results.get(\"qsd\", {})\n\n        # Support to= alias\n        if \"to\" in qd and qd.get(\"to\"):\n            results[\"targets\"] += NotifyXMPP.parse_list(\n                NotifyXMPP.unquote(qd.get(\"to\"))\n            )\n\n        if \"mode\" in results[\"qsd\"] and len(results[\"qsd\"][\"mode\"]):\n            # Extract the secure mode to over-ride the default\n            results[\"secure_mode\"] = results[\"qsd\"][\"mode\"].lower()\n\n        if \"roster\" in results[\"qsd\"] and len(results[\"qsd\"][\"roster\"]):\n            results[\"roster\"] = parse_bool(results[\"qsd\"][\"roster\"])\n\n        if \"subject\" in results[\"qsd\"] and len(results[\"qsd\"][\"subject\"]):\n            results[\"subject\"] = parse_bool(results[\"qsd\"][\"subject\"])\n\n        if \"keepalive\" in results[\"qsd\"] and len(results[\"qsd\"][\"keepalive\"]):\n            results[\"keepalive\"] = parse_bool(results[\"qsd\"][\"keepalive\"])\n\n        return results\n"
  },
  {
    "path": "apprise/plugins/xmpp/common.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"XMPP General/Shared Configuration\"\"\"\n\n\nclass SecureXMPPMode:\n    \"\"\"\n    Defines our modes\n    \"\"\"\n    NONE = \"none\"\n    TLS = \"tls\"\n    STARTTLS = \"starttls\"\n\n\nSECURE_MODES = {\n    SecureXMPPMode.STARTTLS: {\n        \"default_port\": 5222,\n         \"enable_plaintext\": False,\n         \"enable_starttls\": True,\n         \"enable_direct_tls\": False,\n    },\n    SecureXMPPMode.TLS: {\n        \"default_port\": 5223,\n         \"enable_plaintext\": False,\n         \"enable_starttls\": False,\n         \"enable_direct_tls\": True,\n    },\n    SecureXMPPMode.NONE: {\n        \"default_port\": 5222,\n         \"enable_plaintext\": True,\n         \"enable_starttls\": False,\n         \"enable_direct_tls\": False,\n    },\n}\n"
  },
  {
    "path": "apprise/plugins/zulip.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# To use this plugin, you must have a ZulipChat bot defined; See here:\n#  https://zulipchat.com/help/add-a-bot-or-integration\n#\n# At the time of writing this plugin the instructions were:\n#    1. From your desktop, click on the gear icon in the upper right corner.\n#    2. Select Settings.\n#    3. On the left, click Your bots.\n#    4. Click Add a new bot.\n#    5. Fill out the fields, and click Create bot.\n\n# If you know your organization {ID} (as it's part of the zulipchat.com url\n# after you signup, then you can also access your bot information by visting:\n#   https://ID.zulipchat.com/#settings/your-bots\n\n# For example, I create an organization called apprise.  Thus my URL would be\n#   https://apprise.zulipchat.com/#settings/your-bots\n\n#  When you're done and have a bot, it's important to remember the username\n#  you provided the bot and the API key generated.\n#\n#  If your {user} was   : goober-bot@apprise.zulipchat.com\n#  and your {apikey} was: lqn6mpwpam6VZzbCW0o7olmk3hwbQSK\n#\n#  Then the following URLs would be accepted by Apprise:\n#   - zulip://goober-bot@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK\n#   - zulip://goober-bot@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK\n#   - zulip://goober@apprise/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK\n#   - zulip://goober@apprise.zulipchat.com/lqn6mpwpam6VZzbCW0o7olmk3hwbQSK\n\n# The API reference used to build this plugin was documented here:\n#  https://zulipchat.com/api/send-message\n#\nimport re\n\nimport requests\n\nfrom ..common import NotifyType\nfrom ..locale import gettext_lazy as _\nfrom ..utils.parse import is_email, parse_list, validate_regex\nfrom .base import NotifyBase\n\n# A Valid Bot Name\nVALIDATE_BOTNAME = re.compile(r\"(?P<name>[A-Z0-9_-]{1,32})\", re.I)\n\n# Organization required as part of the API request\nVALIDATE_ORG = re.compile(\n    r\"(?P<org>[A-Z0-9_-]{1,32})(\\.(?P<hostname>[^\\s]+))?\", re.I\n)\n\n# Extend HTTP Error Messages\nZULIP_HTTP_ERROR_MAP = {\n    401: \"Unauthorized - Invalid Token.\",\n}\n\n# Used to break path apart into list of streams\nTARGET_LIST_DELIM = re.compile(r\"[ \\t\\r\\n,#\\\\/]+\")\n\n# Used to detect a streams\nIS_VALID_TARGET_RE = re.compile(r\"#?(?P<stream>[A-Z0-9_]{1,32})\", re.I)\n\n\nclass NotifyZulip(NotifyBase):\n    \"\"\"A wrapper for Zulip Notifications.\"\"\"\n\n    # The default descriptive name associated with the Notification\n    service_name = \"Zulip\"\n\n    # The services URL\n    service_url = \"https://zulipchat.com/\"\n\n    # The default secure protocol\n    secure_protocol = \"zulip\"\n\n    # A URL that takes you to the setup/help of the specific protocol\n    setup_url = \"https://appriseit.com/services/zulip/\"\n\n    # Zulip uses the http protocol with JSON requests\n    notify_url = \"https://{org}.{hostname}/api/v1/messages\"\n\n    # The maximum allowable characters allowed in the title per message\n    title_maxlen = 60\n\n    # The maximum allowable characters allowed in the body per message\n    body_maxlen = 10000\n\n    # Define object templates\n    templates = (\n        \"{schema}://{botname}@{organization}/{token}\",\n        \"{schema}://{botname}@{organization}/{token}/{targets}\",\n    )\n\n    # Define our template tokens\n    template_tokens = dict(\n        NotifyBase.template_tokens,\n        **{\n            \"botname\": {\n                \"name\": _(\"Bot Name\"),\n                \"type\": \"string\",\n                \"regex\": (r\"^[A-Z0-9_-]{1,32}$\", \"i\"),\n                \"required\": True,\n            },\n            \"organization\": {\n                \"name\": _(\"Organization\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"regex\": (r\"^[A-Z0-9_-]{1,32})$\", \"i\"),\n            },\n            \"token\": {\n                \"name\": _(\"Token\"),\n                \"type\": \"string\",\n                \"required\": True,\n                \"private\": True,\n                \"regex\": (r\"^[A-Z0-9]{32}$\", \"i\"),\n            },\n            \"target_user\": {\n                \"name\": _(\"Target User\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"target_stream\": {\n                \"name\": _(\"Target Stream\"),\n                \"type\": \"string\",\n                \"map_to\": \"targets\",\n            },\n            \"targets\": {\n                \"name\": _(\"Targets\"),\n                \"type\": \"list:string\",\n            },\n        },\n    )\n\n    # Define our template arguments\n    template_args = dict(\n        NotifyBase.template_args,\n        **{\n            \"to\": {\n                \"alias_of\": \"targets\",\n            },\n            \"token\": {\n                \"alias_of\": \"token\",\n            },\n        },\n    )\n\n    # The default hostname to append to a defined organization\n    # if one isn't defined in the apprise url\n    default_hostname = \"zulipchat.com\"\n\n    # The default stream to notify if no targets are specified\n    default_notification_stream = \"general\"\n\n    def __init__(self, botname, organization, token, targets=None, **kwargs):\n        \"\"\"Initialize Zulip Object.\"\"\"\n        super().__init__(**kwargs)\n\n        # our default hostname\n        self.hostname = self.default_hostname\n\n        try:\n            match = VALIDATE_BOTNAME.match(botname.strip())\n            if not match:\n                # let outer exception handle this\n                raise TypeError\n\n            # The botname\n            botname = match.group(\"name\")\n            suffix = \"-bot\"\n            # Eliminate suffix if found\n            botname = (\n                botname[: -len(suffix)]\n                if botname.endswith(suffix)\n                else botname\n            )\n            self.botname = botname\n\n        except (TypeError, AttributeError) as err:\n            msg = f\"The Zulip botname specified ({botname}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg) from err\n\n        try:\n            match = VALIDATE_ORG.match(organization.strip())\n            if not match:\n                # let outer exception handle this\n                raise TypeError\n\n            # The organization\n            self.organization = match.group(\"org\")\n            if match.group(\"hostname\"):\n                self.hostname = match.group(\"hostname\")\n\n        except (TypeError, AttributeError) as err:\n            msg = (\n                \"The Zulip organization specified \"\n                f\"({organization}) is invalid.\"\n            )\n            self.logger.warning(msg)\n            raise TypeError(msg) from err\n\n        self.token = validate_regex(\n            token, *self.template_tokens[\"token\"][\"regex\"]\n        )\n        if not self.token:\n            msg = f\"The Zulip token specified ({token}) is invalid.\"\n            self.logger.warning(msg)\n            raise TypeError(msg)\n\n        self.targets = parse_list(targets)\n        if len(self.targets) == 0:\n            # No streams identified, use default\n            self.targets.append(self.default_notification_stream)\n\n    def send(self, body, title=\"\", notify_type=NotifyType.INFO, **kwargs):\n        \"\"\"Perform Zulip Notification.\"\"\"\n\n        headers = {\n            \"User-Agent\": self.app_id,\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=utf-8\",\n        }\n\n        # error tracking (used for function return)\n        has_error = False\n\n        # Prepare our notification URL\n        url = self.notify_url.format(\n            org=self.organization,\n            hostname=self.hostname,\n        )\n\n        # prepare JSON Object\n        payload = {\n            \"subject\": title,\n            \"content\": body,\n        }\n\n        # Determine Authentication\n        auth = (\n            f\"{self.botname}-bot@{self.organization}.{self.hostname}\",\n            self.token,\n        )\n\n        # Create a copy of the target list\n        targets = list(self.targets)\n        while len(targets):\n            target = targets.pop(0)\n            result = is_email(target)\n            if result:\n                # Send a private message\n                payload[\"type\"] = \"private\"\n            else:\n                # Send a stream message\n                payload[\"type\"] = \"stream\"\n\n            # Set our target\n            payload[\"to\"] = target if not result else result[\"full_email\"]\n\n            self.logger.debug(\n                f\"Zulip POST URL: {url} \"\n                f\"(cert_verify={self.verify_certificate!r})\"\n            )\n            self.logger.debug(f\"Zulip Payload: {payload!s}\")\n\n            # Always call throttle before any remote server i/o is made\n            self.throttle()\n            try:\n                r = requests.post(\n                    url,\n                    data=payload,\n                    headers=headers,\n                    auth=auth,\n                    verify=self.verify_certificate,\n                    timeout=self.request_timeout,\n                )\n                if r.status_code != requests.codes.ok:\n                    # We had a problem\n                    status_str = NotifyZulip.http_response_code_lookup(\n                        r.status_code, ZULIP_HTTP_ERROR_MAP\n                    )\n\n                    self.logger.warning(\n                        \"Failed to send Zulip notification to {}: \"\n                        \"{}{}error={}.\".format(\n                            target,\n                            status_str,\n                            \", \" if status_str else \"\",\n                            r.status_code,\n                        )\n                    )\n\n                    self.logger.debug(\n                        \"Response Details:\\r\\n%r\", (r.content or b\"\")[:2000])\n\n                    # Mark our failure\n                    has_error = True\n                    continue\n\n                else:\n                    self.logger.info(f\"Sent Zulip notification to {target}.\")\n\n            except requests.RequestException as e:\n                self.logger.warning(\n                    \"A Connection error occurred sending Zulip \"\n                    f\"notification to {target}.\"\n                )\n                self.logger.debug(f\"Socket Exception: {e!s}\")\n\n                # Mark our failure\n                has_error = True\n                continue\n\n        return not has_error\n\n    @property\n    def url_identifier(self):\n        \"\"\"Returns all of the identifiers that make this URL unique from\n        another simliar one.\n\n        Targets or end points should never be identified here.\n        \"\"\"\n        return (\n            self.secure_protocol,\n            self.organization,\n            self.hostname,\n            self.token,\n        )\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Returns the URL built dynamically based on specified arguments.\"\"\"\n\n        # Our URL parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        # simplify our organization in our URL if we can\n        organization = \"{}{}\".format(\n            self.organization,\n            (\n                f\".{self.hostname}\"\n                if self.hostname != self.default_hostname\n                else \"\"\n            ),\n        )\n\n        return \"{schema}://{botname}@{org}/{token}/{targets}?{params}\".format(\n            schema=self.secure_protocol,\n            botname=NotifyZulip.quote(self.botname, safe=\"\"),\n            org=NotifyZulip.quote(organization, safe=\"\"),\n            token=self.pprint(self.token, privacy, safe=\"\"),\n            targets=\"/\".join(\n                [NotifyZulip.quote(x, safe=\"\") for x in self.targets]\n            ),\n            params=NotifyZulip.urlencode(params),\n        )\n\n    def __len__(self):\n        \"\"\"Returns the number of targets associated with this notification.\"\"\"\n        return len(self.targets)\n\n    @staticmethod\n    def parse_url(url):\n        \"\"\"Parses the URL and returns enough arguments that can allow us to re-\n        instantiate this object.\"\"\"\n        results = NotifyBase.parse_url(url, verify_host=False)\n        if not results:\n            # We're done early as we couldn't load the results\n            return results\n\n        # The botname\n        results[\"botname\"] = NotifyZulip.unquote(results[\"user\"])\n\n        # The organization is stored in the hostname\n        results[\"organization\"] = NotifyZulip.unquote(results[\"host\"])\n\n        # Store our targets\n        results[\"targets\"] = NotifyZulip.split_path(results[\"fullpath\"])\n\n        if \"token\" in results[\"qsd\"] and len(results[\"qsd\"][\"token\"]):\n            # Store our token if specified\n            results[\"token\"] = NotifyZulip.unquote(results[\"qsd\"][\"token\"])\n\n        elif results[\"targets\"]:\n            # First item is the token\n            results[\"token\"] = results[\"targets\"].pop(0)\n\n        else:\n            # no token\n            results[\"token\"] = None\n\n        # Support the 'to' variable so that we can support rooms this way too\n        # The 'to' makes it easier to use yaml configuration\n        if \"to\" in results[\"qsd\"] and len(results[\"qsd\"][\"to\"]):\n            results[\"targets\"] += list(\n                filter(\n                    bool,\n                    TARGET_LIST_DELIM.split(\n                        NotifyZulip.unquote(results[\"qsd\"][\"to\"])\n                    ),\n                )\n            )\n\n        return results\n"
  },
  {
    "path": "apprise/py.typed",
    "content": ""
  },
  {
    "path": "apprise/url.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime\nimport hashlib\nimport re\nimport sys\nimport time\nfrom urllib.parse import quote as _quote, unquote as _unquote\nfrom xml.sax.saxutils import escape as sax_escape\n\nfrom .asset import AppriseAsset\nfrom .locale import gettext_lazy as _\nfrom .logger import logger\nfrom .utils.parse import (\n    parse_bool,\n    parse_list,\n    parse_phone_no,\n    parse_url,\n    urlencode,\n)\n\n# Used to break a path list into parts\nPATHSPLIT_LIST_DELIM = re.compile(r\"[ \\t\\r\\n,\\\\/]+\")\n\n\nclass PrivacyMode:\n    # Defines different privacy modes strings can be printed as\n    # Astrisk sets 4 of them: e.g. ****\n    # This is used for passwords\n    Secret = \"*\"\n\n    # Outer takes the first and last character displaying them with\n    # 3 dots between.  Hence, 'i-am-a-token' would become 'i...n'\n    Outer = \"o\"\n\n    # Displays the last four characters\n    Tail = \"t\"\n\n\n# Define the HTML Lookup Table\nHTML_LOOKUP = {\n    400: \"Bad Request - Unsupported Parameters.\",\n    401: \"Verification Failed.\",\n    404: \"Page not found.\",\n    405: \"Method not allowed.\",\n    500: \"Internal server error.\",\n    503: \"Servers are overloaded.\",\n}\n\n\nclass URLBase:\n    \"\"\"This is the base class for all URL Manipulation.\"\"\"\n\n    # The default descriptive name associated with the URL\n    service_name = None\n\n    # The default simple (insecure) protocol\n    # all inheriting entries must provide their protocol lookup\n    # protocol:// (in this example they would specify 'protocol')\n    protocol = None\n\n    # The default secure protocol\n    # all inheriting entries must provide their protocol lookup\n    # protocols:// (in this example they would specify 'protocols')\n    # This value can be the same as the defined protocol.\n    secure_protocol = None\n\n    # Throttle\n    request_rate_per_sec = 0\n\n    # The connect timeout is the number of seconds Requests will wait for your\n    # client to establish a connection to a remote machine (corresponding to\n    # the connect()) call on the socket.\n    socket_connect_timeout = 4.0\n\n    # The read timeout is the number of seconds the client will wait for the\n    # server to send a response.\n    socket_read_timeout = 4.0\n\n    # provide the information required to allow for unique id generation when\n    # calling url_id().  Over-ride this in calling classes. Calling classes\n    # should set this to false if there can be no url_id generated\n    url_identifier = None\n\n    # Tracks the last generated url_id() to prevent regeneration; initializes\n    # to False and is set thereafter.  This is an internal value for this class\n    # only and should not be set to anything other then False below...\n    __cached_url_identifier = False\n\n    # Handle\n    # Maintain a set of tags to associate with this specific notification\n    tags = set()\n\n    # Secure sites should be verified against a Certificate Authority\n    verify_certificate = True\n\n    # Logging to our global logger\n    logger = logger\n\n    # Define a default set of template arguments used for dynamically building\n    # details about our individual plugins for developers.\n\n    # Define object templates\n    templates = ()\n\n    # Provides a mapping of tokens, certain entries are fixed and automatically\n    # configured if found (such as schema, host, user, pass, and port)\n    template_tokens = {}\n\n    # Here is where we define all of the arguments we accept on the url\n    # such as: schema://whatever/?cto=5.0&rto=15\n    # These act the same way as tokens except they are optional and/or\n    # have default values set if mandatory. This rule must be followed\n    template_args = {\n        \"verify\": {\n            \"name\": _(\"Verify SSL\"),\n            # SSL Certificate Authority Verification\n            \"type\": \"bool\",\n            # Provide a default\n            \"default\": verify_certificate,\n            # look up default using the following parent class value at\n            # runtime.\n            \"_lookup_default\": \"verify_certificate\",\n        },\n        \"rto\": {\n            \"name\": _(\"Socket Read Timeout\"),\n            \"type\": \"float\",\n            # Provide a default\n            \"default\": socket_read_timeout,\n            # look up default using the following parent class value at\n            # runtime. The variable name identified here (in this case\n            # socket_read_timeout) is checked and it's result is placed\n            # over-top of  the 'default'. This is done because once a parent\n            # class inherits this one, the overflow_mode already set as a\n            # default 'could' be potentially over-ridden and changed to a\n            # different value.\n            \"_lookup_default\": \"socket_read_timeout\",\n        },\n        \"cto\": {\n            \"name\": _(\"Socket Connect Timeout\"),\n            \"type\": \"float\",\n            # Provide a default\n            \"default\": socket_connect_timeout,\n            # look up default using the following parent class value at\n            # runtime. The variable name identified here (in this case\n            # socket_connect_timeout) is checked and it's result is placed\n            # over-top of  the 'default'. This is done because once a parent\n            # class inherits this one, the overflow_mode already set as a\n            # default 'could' be potentially over-ridden and changed to a\n            # different value.\n            \"_lookup_default\": \"socket_connect_timeout\",\n        },\n    }\n\n    # kwargs are dynamically built because a prefix causes us to parse the\n    # content slightly differently. The prefix is required and can be either\n    # a (+ or -). Below would handle the +key=value:\n    #    {\n    #        'headers': {\n    #           'name': _('HTTP Header'),\n    #           'prefix': '+',\n    #           'type': 'string',\n    #        },\n    #    },\n    #\n    # In a kwarg situation, the 'key' is always presumed to be treated as\n    # a string.  When the 'type' is defined, it is being defined to respect\n    # the 'value'.\n\n    template_kwargs = {}\n\n    # Internal Values\n\n    def __init__(self, asset=None, **kwargs):\n        \"\"\"Initialize some general logging and common server arguments that\n        will keep things consistent when working with the children that inherit\n        this class.\"\"\"\n        # Prepare our Asset Object\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        # Certificate Verification (for SSL calls); default to being enabled\n        self.verify_certificate = parse_bool(\n            kwargs.get(\"verify\", URLBase.verify_certificate)\n        )\n\n        # Schema\n        self.schema = kwargs.get(\"schema\", \"unknown\").lower()\n\n        # Secure Mode\n        self.secure = kwargs.get(\"secure\")\n        if not isinstance(self.secure, bool):\n            # Attempt to detect\n            self.secure = self.schema[-1:] == \"s\"\n\n        self.host = URLBase.unquote(kwargs.get(\"host\"))\n        self.port = kwargs.get(\"port\")\n        if self.port:\n            try:\n                self.port = int(self.port)\n\n            except (TypeError, ValueError):\n                self.logger.warning(\n                    f\"Invalid port number specified {self.port}\"\n                )\n                self.port = None\n\n        self.user = kwargs.get(\"user\")\n        if self.user:\n            # Always unquote user if it exists\n            self.user = URLBase.unquote(self.user)\n\n        self.password = kwargs.get(\"password\")\n        if self.password:\n            # Always unquote the password if it exists\n            self.password = URLBase.unquote(self.password)\n\n        # Store our full path consistently ensuring it ends with a `/'\n        self.fullpath = URLBase.unquote(kwargs.get(\"fullpath\"))\n        if not isinstance(self.fullpath, str) or not self.fullpath:\n            self.fullpath = \"/\"\n\n        # Store our Timeout Variables\n        if \"rto\" in kwargs:\n            try:\n                self.socket_read_timeout = float(kwargs.get(\"rto\"))\n            except (TypeError, ValueError):\n                self.logger.warning(\n                    \"Invalid socket read timeout (rto) was specified {}\"\n                    .format(kwargs.get(\"rto\"))\n                )\n\n        if \"cto\" in kwargs:\n            try:\n                self.socket_connect_timeout = float(kwargs.get(\"cto\"))\n\n            except (TypeError, ValueError):\n                self.logger.warning(\n                    \"Invalid socket connect timeout (cto) was specified {}\"\n                    .format(kwargs.get(\"cto\"))\n                )\n\n        if \"tag\" in kwargs:\n            # We want to associate some tags with our notification service.\n            # the code below gets the 'tag' argument if defined, otherwise\n            # it just falls back to whatever was already defined globally\n            self.tags = set(parse_list(kwargs.get(\"tag\"), self.tags))\n\n        # Tracks the time any i/o was made to the remote server.  This value\n        # is automatically set and controlled through the throttle() call.\n        self._last_io_datetime = None\n\n    def throttle(self, last_io=None, wait=None):\n        \"\"\"A common throttle control.\n\n        if a wait is specified, then it will force a sleep of the specified\n        time if it is larger then the calculated throttle time.\n        \"\"\"\n\n        if last_io is not None:\n            # Assume specified last_io\n            self._last_io_datetime = last_io\n\n        # Get ourselves a reference time of 'now'\n        reference = datetime.now()\n\n        if self._last_io_datetime is None:\n            # Set time to 'now' and no need to throttle\n            self._last_io_datetime = reference\n            return\n\n        if self.request_rate_per_sec <= 0.0 and not wait:\n            # We're done if there is no throttle limit set\n            return\n\n        # If we reach here, we need to do additional logic.\n        # If the difference between the reference time and 'now' is less than\n        # the defined request_rate_per_sec then we need to throttle for the\n        # remaining balance of this time.\n\n        elapsed = (reference - self._last_io_datetime).total_seconds()\n\n        if wait is not None:\n            self.logger.debug(f\"Throttling forced for {wait}s...\")\n            time.sleep(wait)\n\n        elif elapsed < self.request_rate_per_sec:\n            self.logger.debug(\n                f\"Throttling for {self.request_rate_per_sec - elapsed}s...\"\n            )\n            time.sleep(self.request_rate_per_sec - elapsed)\n\n        # Update our timestamp before we leave\n        self._last_io_datetime = datetime.now()\n        return\n\n    def url(self, privacy=False, *args, **kwargs):\n        \"\"\"Assembles the URL associated with the notification based on the\n        arguments provied.\"\"\"\n\n        # Our default parameters\n        params = self.url_parameters(privacy=privacy, *args, **kwargs)\n\n        # Determine Authentication\n        auth = \"\"\n        if self.user and self.password:\n            auth = \"{user}:{password}@\".format(\n                user=URLBase.quote(self.user, safe=\"\"),\n                password=self.pprint(\n                    self.password, privacy, mode=PrivacyMode.Secret, safe=\"\"\n                ),\n            )\n        elif self.user:\n            auth = \"{user}@\".format(\n                user=URLBase.quote(self.user, safe=\"\"),\n            )\n\n        default_port = 443 if self.secure else 80\n        return \"{schema}://{auth}{hostname}{port}{fullpath}{params}\".format(\n            schema=\"https\" if self.secure else \"http\",\n            auth=auth,\n            # never encode hostname since we're expecting it to be a valid one\n            hostname=self.host,\n            port=(\n                \"\"\n                if self.port is None or self.port == default_port\n                else f\":{self.port}\"\n            ),\n            fullpath=(\n                URLBase.quote(self.fullpath, safe=\"/\")\n                if self.fullpath\n                else \"/\"\n            ),\n            params=(\"?\" + URLBase.urlencode(params) if params else \"\"),\n        )\n\n    def url_id(self, lazy=True, hash_engine=hashlib.sha256):\n        \"\"\"Returns a unique URL identifier that representing the Apprise URL\n        itself. The url_id is always a hash string or None if it can't be\n        generated.\n\n        The idea is to only build the ID based on the credentials or specific\n        elements relative to the URL itself. The URL ID should never factor in\n        (or else it's a bug) the following:\n          - any targets defined\n          - all GET parameters options unless they explicitly change the\n            complete function of the code.\n\n             For example: GET parameters like ?image=false&avatar=no should\n             have no bearing in the uniqueness of the Apprise URL Identifier.\n\n             Consider plugins where some get parameters completely change\n             how the entire upstream comunication works such as slack:// and\n             matrix:// which has a mode. In these circumstances, they should\n             be considered in he unique generation.\n\n        The intention of this function is to help align Apprise URLs that are\n        common with one another and therefore can share the same persistent\n        storage even when subtle changes are made to them.\n\n        Hence the following would all return the same URL Identifier:\n             json://abc/def/ghi?image=no\n             json://abc/def/ghi/?test=yes&image=yes\n        \"\"\"\n\n        if lazy and self.__cached_url_identifier is not False:\n            return (\n                self.__cached_url_identifier\n                if not (\n                    self.__cached_url_identifier and self.asset.storage_idlen\n                )\n                else self.__cached_url_identifier[: self.asset.storage_idlen]\n            )\n\n        # Python v3.9 introduces usedforsecurity argument\n        kwargs = (\n            {\"usedforsecurity\": False} if sys.version_info >= (3, 9) else {}\n        )\n\n        if self.url_identifier is False:\n            # Disabled\n            self.__cached_url_identifier = None\n\n        elif self.url_identifier in (None, True):\n\n            # Prepare our object\n            engine = hash_engine(\n                self.asset.storage_salt\n                + self.schema.encode(self.asset.encoding),\n                **kwargs,\n            )\n\n            # We want to treat `None` differently then a blank entry\n            engine.update(\n                b\"\\0\"\n                if self.password is None\n                else self.password.encode(self.asset.encoding)\n            )\n            engine.update(\n                b\"\\0\"\n                if self.user is None\n                else self.user.encode(self.asset.encoding)\n            )\n            engine.update(\n                b\"\\0\"\n                if not self.host\n                else self.host.encode(self.asset.encoding)\n            )\n            engine.update(\n                b\"\\0\"\n                if self.port is None\n                else f\"{self.port}\".encode(self.asset.encoding)\n            )\n            engine.update(\n                self.fullpath.rstrip(\"/\").encode(self.asset.encoding)\n            )\n            engine.update(b\"s\" if self.secure else b\"i\")\n\n            # Save our generated content\n            self.__cached_url_identifier = engine.hexdigest()\n\n        elif isinstance(self.url_identifier, str):\n            self.__cached_url_identifier = hash_engine(\n                self.asset.storage_salt\n                + self.url_identifier.encode(self.asset.encoding),\n                **kwargs,\n            ).hexdigest()\n\n        elif isinstance(self.url_identifier, bytes):\n            self.__cached_url_identifier = hash_engine(\n                self.asset.storage_salt + self.url_identifier, **kwargs\n            ).hexdigest()\n\n        elif isinstance(self.url_identifier, (list, tuple, set)):\n            self.__cached_url_identifier = hash_engine(\n                self.asset.storage_salt\n                + b\"\".join([\n                    (\n                        x\n                        if isinstance(x, bytes)\n                        else str(x).encode(self.asset.encoding)\n                    )\n                    for x in self.url_identifier\n                ]),\n                **kwargs,\n            ).hexdigest()\n\n        elif isinstance(self.url_identifier, dict):\n            self.__cached_url_identifier = hash_engine(\n                self.asset.storage_salt\n                + b\"\".join([\n                    (\n                        x\n                        if isinstance(x, bytes)\n                        else str(x).encode(self.asset.encoding)\n                    )\n                    for x in self.url_identifier.values()\n                ]),\n                **kwargs,\n            ).hexdigest()\n\n        else:\n            self.__cached_url_identifier = hash_engine(\n                self.asset.storage_salt\n                + str(self.url_identifier).encode(self.asset.encoding),\n                **kwargs,\n            ).hexdigest()\n\n        return (\n            self.__cached_url_identifier\n            if not (self.__cached_url_identifier and self.asset.storage_idlen)\n            else self.__cached_url_identifier[: self.asset.storage_idlen]\n        )\n\n    def __contains__(self, tags):\n        \"\"\"Returns true if the tag specified is associated with this\n        notification.\n\n        tag can also be a tuple, set, and/or list\n        \"\"\"\n        if isinstance(tags, (tuple, set, list)):\n            return bool(set(tags) & self.tags)\n\n        # return any match\n        return tags in self.tags\n\n    def __str__(self):\n        \"\"\"Returns the url path.\"\"\"\n        return self.url(privacy=True)\n\n    @staticmethod\n    def escape_html(html, convert_new_lines=False, whitespace=True):\n        \"\"\"Takes html text as input and escapes it so that it won't conflict\n        with any xml/html wrapping characters.\n\n        Args:\n            html (str): The HTML code to escape\n            convert_new_lines (:obj:`bool`, optional): escape new lines (\\n)\n            whitespace (:obj:`bool`, optional): escape whitespace\n\n        Returns:\n            str: The escaped html\n        \"\"\"\n        if not isinstance(html, str) or not html:\n            return \"\"\n\n        # Escape HTML\n        escaped = sax_escape(html, {\"'\": \"&apos;\", '\"': \"&quot;\"})\n\n        if whitespace:\n            # Tidy up whitespace too\n            escaped = escaped.replace(\"\\t\", \"&emsp;\").replace(\" \", \"&nbsp;\")\n\n        if convert_new_lines:\n            return escaped.replace(\"\\n\", \"<br/>\")\n\n        return escaped\n\n    @staticmethod\n    def unquote(content, encoding=\"utf-8\", errors=\"replace\"):\n        \"\"\"Replace %xx escapes by their single-character equivalent. The\n        optional encoding and errors parameters specify how to decode percent-\n        encoded sequences.\n\n        Wrapper to Python's `unquote` while remaining compatible with both\n        Python 2 & 3 since the reference to this function changed between\n        versions.\n\n        Note: errors set to 'replace' means that invalid sequences are\n              replaced by a placeholder character.\n\n        Args:\n            content (str): The quoted URI string you wish to unquote\n            encoding (:obj:`str`, optional): encoding type\n            errors (:obj:`str`, errors): how to handle invalid character found\n                in encoded string (defined by encoding)\n\n        Returns:\n            str: The unquoted URI string\n        \"\"\"\n        if not content:\n            return \"\"\n\n        return _unquote(content, encoding=encoding, errors=errors)\n\n    @staticmethod\n    def quote(content, safe=\"/\", encoding=None, errors=None):\n        \"\"\"Replaces single character non-ascii characters and URI specific ones\n        by their %xx code.\n\n        Wrapper to Python's `quote` while remaining compatible with both\n        Python 2 & 3 since the reference to this function changed between\n        versions.\n\n        Args:\n            content (str): The URI string you wish to quote\n            safe (str): non-ascii characters and URI specific ones that you\n                        do not wish to escape (if detected). Setting this\n                        string to an empty one causes everything to be\n                        escaped.\n            encoding (:obj:`str`, optional): encoding type\n            errors (:obj:`str`, errors): how to handle invalid character found\n                in encoded string (defined by encoding)\n\n        Returns:\n            str: The quoted URI string\n        \"\"\"\n        if not content:\n            return \"\"\n\n        return _quote(content, safe=safe, encoding=encoding, errors=errors)\n\n    @staticmethod\n    def pprint(\n        content,\n        privacy=True,\n        mode=PrivacyMode.Outer,\n        # privacy print; quoting is ignored when privacy is set to True\n        quote=True,\n        safe=\"/\",\n        encoding=None,\n        errors=None,\n    ):\n        \"\"\"Privacy Print is used to mainpulate the string before passing it\n        into part of the URL.  It is used to mask/hide private details such as\n        tokens, passwords, apikeys, etc from on-lookers.  If the privacy=False\n        is set, then the quote variable is the next flag checked.\n\n        Quoting is never done if the privacy flag is set to true to avoid\n        skewing the expected output.\n        \"\"\"\n\n        if not privacy:\n            if quote:\n                # Return quoted string if specified to do so\n                return URLBase.quote(\n                    content, safe=safe, encoding=encoding, errors=errors\n                )\n\n                # Return content 'as-is'\n            return content\n\n        if mode is PrivacyMode.Secret:\n            # Return 4 Asterisks\n            return \"****\"\n\n        if not isinstance(content, str) or not content:\n            # Nothing more to do\n            return \"\"\n\n        if mode is PrivacyMode.Tail:\n            # Return the trailing 4 characters\n            return f\"...{content[-4:]}\"\n\n        # Default mode is Outer Mode\n        return f\"{content[0:1]}...{content[-1:]}\"\n\n    @staticmethod\n    def urlencode(query, doseq=False, safe=\"\", encoding=None, errors=None):\n        \"\"\"Convert a mapping object or a sequence of two-element tuples.\n\n        Wrapper to Python's `urlencode` while remaining compatible with both\n        Python 2 & 3 since the reference to this function changed between\n        versions.\n\n        The resulting string is a series of key=value pairs separated by '&'\n        characters, where both key and value are quoted using the quote()\n        function.\n\n        Note: If the dictionary entry contains an entry that is set to None\n              it is not included in the final result set. If you want to\n              pass in an empty variable, set it to an empty string.\n\n        Args:\n            query (str): The dictionary to encode\n            doseq (:obj:`bool`, optional): Handle sequences\n            safe (:obj:`str`): non-ascii characters and URI specific ones that\n                you do not wish to escape (if detected). Setting this string\n                to an empty one causes everything to be escaped.\n            encoding (:obj:`str`, optional): encoding type\n            errors (:obj:`str`, errors): how to handle invalid character found\n                in encoded string (defined by encoding)\n\n        Returns:\n            str: The escaped parameters returned as a string\n        \"\"\"\n        return urlencode(\n            query, doseq=doseq, safe=safe, encoding=encoding, errors=errors\n        )\n\n    @staticmethod\n    def split_path(path, unquote=True):\n        \"\"\"Splits a URL up into a list object.\n\n        Parses a specified URL and breaks it into a list.\n\n        Args:\n            path (str): The path to split up into a list.\n            unquote (:obj:`bool`, optional): call unquote on each element\n                 added to the returned list.\n\n        Returns:\n            list: A list containing all of the elements in the path\n        \"\"\"\n\n        try:\n            paths = PATHSPLIT_LIST_DELIM.split(path.lstrip(\"/\"))\n            if unquote:\n                paths = [URLBase.unquote(x) for x in filter(bool, paths)]\n\n        except AttributeError:\n            # path is not useable, we still want to gracefully return an\n            # empty list\n            paths = []\n\n        return paths\n\n    @staticmethod\n    def parse_list(content, allow_whitespace=True, unquote=True):\n        \"\"\"A wrapper to utils.parse_list() with unquoting support.\n\n        Parses a specified set of data and breaks it into a list.\n\n        Args:\n            content (str): The path to split up into a list. If a list is\n                 provided, then it's individual entries are processed.\n\n            allow_whitespace (:obj:`bool`, optional): whitespace is to be\n                 treated as a delimiter\n\n            unquote (:obj:`bool`, optional): call unquote on each element\n                 added to the returned list.\n\n        Returns:\n            list: A unique list containing all of the elements in the path\n        \"\"\"\n\n        content = parse_list(content, allow_whitespace=allow_whitespace)\n        if unquote:\n            content = [URLBase.unquote(x) for x in filter(bool, content)]\n\n        return content\n\n    @staticmethod\n    def parse_phone_no(content, unquote=True, prefix=False):\n        \"\"\"A wrapper to utils.parse_phone_no() with unquoting support.\n\n        Parses a specified set of data and breaks it into a list.\n\n        Args:\n            content (str): The path to split up into a list. If a list is\n                 provided, then it's individual entries are processed.\n\n            unquote (:obj:`bool`, optional): call unquote on each element\n                 added to the returned list.\n\n        Returns:\n            list: A unique list containing all of the elements in the path\n        \"\"\"\n\n        if unquote:\n            try:\n                content = URLBase.unquote(content)\n            except TypeError:\n                # Nothing further to do\n                return []\n\n        content = parse_phone_no(content, prefix=prefix)\n\n        return content\n\n    @property\n    def app_id(self):\n        return self.asset.app_id if self.asset.app_id else \"\"\n\n    @property\n    def app_desc(self):\n        return self.asset.app_desc if self.asset.app_desc else \"\"\n\n    @property\n    def app_url(self):\n        return self.asset.app_url if self.asset.app_url else \"\"\n\n    @property\n    def request_timeout(self):\n        \"\"\"This is primarily used to fullfill the `timeout` keyword argument\n        that is used by requests.get() and requests.put() calls.\"\"\"\n        return (self.socket_connect_timeout, self.socket_read_timeout)\n\n    @property\n    def request_auth(self):\n        \"\"\"This is primarily used to fullfill the `auth` keyword argument that\n        is used by requests.get() and requests.put() calls.\"\"\"\n        return (self.user, self.password) if self.user else None\n\n    @property\n    def request_url(self):\n        \"\"\"Assemble a simple URL that can be used by the requests library.\"\"\"\n\n        # Acquire our schema\n        schema = \"https\" if self.secure else \"http\"\n\n        # Prepare our URL\n        url = f\"{schema}://{self.host}\"\n\n        # Apply Port information if present\n        if isinstance(self.port, int):\n            url += f\":{self.port}\"\n\n        # Append our full path\n        return url + self.fullpath\n\n    def url_parameters(self, *args, **kwargs):\n        \"\"\"Provides a default set of args to work with. This can greatly\n        simplify URL construction in the acommpanied url() function.\n\n        The following property returns a dictionary (of strings) containing all\n        of the parameters that can be set on a URL and managed through this\n        class.\n        \"\"\"\n\n        # parameters are only provided on demand to keep the URL short\n        params = {}\n\n        # The socket read timeout\n        if self.socket_read_timeout != URLBase.socket_read_timeout:\n            params[\"rto\"] = str(self.socket_read_timeout)\n\n        # The request/socket connect timeout\n        if self.socket_connect_timeout != URLBase.socket_connect_timeout:\n            params[\"cto\"] = str(self.socket_connect_timeout)\n\n        # Certificate verification\n        if self.verify_certificate != URLBase.verify_certificate:\n            params[\"verify\"] = \"yes\" if self.verify_certificate else \"no\"\n\n        return params\n\n    @staticmethod\n    def post_process_parse_url_results(results):\n        \"\"\"After parsing the URL, this function applies a bit of extra logic to\n        support extra entries like `pass` becoming `password`, etc.\n\n        This function assumes that parse_url() was called previously setting up\n        the basics to be checked\n        \"\"\"\n\n        # if our URL ends with an 's', then assume our secure flag is set.\n        results[\"secure\"] = results[\"schema\"][-1] == \"s\"\n\n        # QSD Checking (over-rides all)\n        qsd_exists = bool(isinstance(results.get(\"qsd\"), dict))\n\n        if qsd_exists and \"verify\" in results[\"qsd\"]:\n            # Pulled from URL String\n            results[\"verify\"] = parse_bool(results[\"qsd\"].get(\"verify\", True))\n\n        elif \"verify\" in results:\n            # Pulled from YAML Configuratoin\n            results[\"verify\"] = parse_bool(results.get(\"verify\", True))\n\n        else:\n            # Support SSL Certificate 'verify' keyword. Default to being\n            # enabled\n            results[\"verify\"] = True\n\n        # Password overrides\n        if \"pass\" in results:\n            results[\"password\"] = results[\"pass\"]\n            del results[\"pass\"]\n\n        if qsd_exists:\n            if \"password\" in results[\"qsd\"]:\n                results[\"password\"] = results[\"qsd\"][\"password\"]\n            if \"pass\" in results[\"qsd\"]:\n                results[\"password\"] = results[\"qsd\"][\"pass\"]\n\n            # User overrides\n            if \"user\" in results[\"qsd\"]:\n                results[\"user\"] = results[\"qsd\"][\"user\"]\n\n            # parse_url() always creates a 'password' and 'user' entry in the\n            # results returned.  Entries are set to None if they weren't\n            # specified\n            if results[\"password\"] is None and \"user\" in results[\"qsd\"]:\n                # Handle cases where the user= provided in 2 locations, we want\n                # the original to fall back as a being a password (if one\n                # wasn't otherwise defined) e.g.\n                #    mailtos://PASSWORD@hostname?user=admin@mail-domain.com\n                # - in the above, the PASSWORD gets lost in the parse url()\n                #   since a user= over-ride is specified.\n                presults = parse_url(results[\"url\"])\n                if presults:\n                    # Store our Password\n                    results[\"password\"] = presults[\"user\"]\n\n            # Store our socket read timeout if specified\n            if \"rto\" in results[\"qsd\"]:\n                results[\"rto\"] = results[\"qsd\"][\"rto\"]\n\n            # Store our socket connect timeout if specified\n            if \"cto\" in results[\"qsd\"]:\n                results[\"cto\"] = results[\"qsd\"][\"cto\"]\n\n            if \"port\" in results[\"qsd\"]:\n                results[\"port\"] = results[\"qsd\"][\"port\"]\n\n        return results\n\n    @staticmethod\n    def parse_url(\n        url,\n        verify_host=True,\n        plus_to_space=False,\n        strict_port=False,\n        sanitize=True,\n    ):\n        \"\"\"Parses the URL and returns it broken apart into a dictionary.\n\n        This is very specific and customized for Apprise.\n\n\n        Args:\n            url (str): The URL you want to fully parse.\n            verify_host (:obj:`bool`, optional): a flag kept with the parsed\n                 URL which some child classes will later use to verify SSL\n                 keys (if SSL transactions take place).  Unless under very\n                 specific circumstances, it is strongly recomended that\n                 you leave this default value set to True.\n\n        Returns:\n            A dictionary is returned containing the URL fully parsed if\n            successful, otherwise None is returned.\n        \"\"\"\n\n        results = parse_url(\n            url,\n            default_schema=\"unknown\",\n            verify_host=verify_host,\n            plus_to_space=plus_to_space,\n            strict_port=strict_port,\n            sanitize=sanitize,\n        )\n\n        if not results:\n            # We're done; we failed to parse our url\n            return results\n\n        return URLBase.post_process_parse_url_results(results)\n\n    @staticmethod\n    def http_response_code_lookup(code, response_mask=None):\n        \"\"\"Parses the interger response code returned by a remote call from a\n        web request into it's human readable string version.\n\n        You can over-ride codes or add new ones by providing your own\n        response_mask that contains a dictionary of integer -> string mapped\n        variables\n        \"\"\"\n        if isinstance(response_mask, dict):\n            # Apply any/all header over-rides defined\n            HTML_LOOKUP.update(response_mask)\n\n        # Look up our response\n        try:\n            response = HTML_LOOKUP[code]\n\n        except KeyError:\n            response = \"\"\n\n        return response\n\n    def __len__(self):\n        \"\"\"Should be over-ridden and allows the tracking of how many targets\n        are associated with each URLBase object.\n\n        Default is always 1\n        \"\"\"\n        return 1\n\n    def schemas(self):\n        \"\"\"A simple function that returns a set of all schemas associated with\n        this object based on the object.protocol and object.secure_protocol.\"\"\"\n\n        schemas = set()\n\n        for key in (\"protocol\", \"secure_protocol\"):\n            schema = getattr(self, key, None)\n            if isinstance(schema, str):\n                schemas.add(schema)\n\n            elif isinstance(schema, (set, list, tuple)):\n                # Support iterables list types\n                for s in schema:\n                    if isinstance(s, str):\n                        schemas.add(s)\n\n        return schemas\n"
  },
  {
    "path": "apprise/utils/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "apprise/utils/base64.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport base64\nimport binascii\nimport copy\nimport json\n\n\ndef base64_urlencode(data: bytes) -> str:\n    \"\"\"URL Safe Base64 Encoding.\"\"\"\n    try:\n        return base64.urlsafe_b64encode(data).rstrip(b\"=\").decode(\"utf-8\")\n\n    except TypeError:\n        # data is not supported; avoid raising exception\n        return None\n\n\ndef base64_urldecode(data: str) -> bytes:\n    \"\"\"URL Safe Base64 Encoding.\"\"\"\n\n    try:\n        # Normalize base64url string (remove padding, add it back)\n        padding = \"=\" * (-len(data) % 4)\n        return base64.urlsafe_b64decode(data + padding)\n\n    except TypeError:\n        # data is not supported; avoid raising exception\n        return None\n\n\ndef decode_b64_dict(di: dict) -> dict:\n    \"\"\"Decodes base64 dictionary previously encoded.\n\n    string entries prefixed with `b64:` are targeted\n    \"\"\"\n    di = copy.deepcopy(di)\n    for k, v in di.items():\n        if not isinstance(v, str) or not v.startswith(\"b64:\"):\n            continue\n\n        try:\n            parsed_v = base64.b64decode(v[4:])\n            parsed_v = json.loads(parsed_v)\n\n        except (\n            ValueError,\n            TypeError,\n            binascii.Error,\n            json.decoder.JSONDecodeError,\n        ):\n            # ValueError: the length of altchars is not 2.\n            # TypeError: invalid input\n            # binascii.Error: not base64 (bad padding)\n            # json.decoder.JSONDecodeError: Bad JSON object\n\n            parsed_v = v\n        di[k] = parsed_v\n    return di\n\n\ndef encode_b64_dict(di: dict, encoding=\"utf-8\") -> tuple[dict, bool]:\n    \"\"\"Encodes dictionary entries containing binary types (int, float) into\n    base64.\n\n    Final product is always string based values\n    \"\"\"\n    di = copy.deepcopy(di)\n    needs_decoding = False\n    for k, v in di.items():\n        if isinstance(v, str):\n            continue\n\n        try:\n            encoded = base64.urlsafe_b64encode(json.dumps(v).encode(encoding))\n            encoded = f\"b64:{encoded.decode(encoding)}\"\n            needs_decoding = True\n\n        except (ValueError, TypeError):\n            # ValueError:\n            #  - the length of altchars is not 2.\n            # TypeError:\n            #  - json not searializable or\n            #  - bytes object not passed into urlsafe_b64encode()\n            encoded = str(v)\n\n        di[k] = encoded\n    return di, needs_decoding\n"
  },
  {
    "path": "apprise/utils/cwe312.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport re\n\nfrom .parse import is_hostname, parse_url\n\n\ndef cwe312_word(word, force=False, advanced=True, threshold=5):\n    \"\"\"This function was written to help mask secure/private information that\n    may or may not be found within Apprise. The idea is to provide a\n    presentable word response that the user who prepared it would understand,\n    yet not reveal any private information for any potential intruder.\n\n    For more detail see CWE-312 @\n       https://cwe.mitre.org/data/definitions/312.html\n\n    The `force` is an optional argument used to keep the string formatting\n    consistent and in one place. If set, the content passed in is presumed\n    to be containing secret information and will be updated accordingly.\n\n    If advanced is set to `True` then content is additionally checked for\n    upper/lower/ascii/numerical variances. If an obscurity threshold is\n    reached, then content is considered secret\n    \"\"\"\n\n    class Variance:\n        \"\"\"A Simple List of Possible Character Variances.\"\"\"\n\n        # An Upper Case Character (ABCDEF... etc)\n        ALPHA_UPPER = \"+\"\n        # An Lower Case Character (abcdef... etc)\n        ALPHA_LOWER = \"-\"\n        # A Special Character ($%^;... etc)\n        SPECIAL = \"s\"\n        # A Numerical Character (1234... etc)\n        NUMERIC = \"n\"\n\n    if not (isinstance(word, str) and word.strip()):\n        # not a password if it's not something we even support\n        return word\n\n    # Formatting\n    word = word.strip()\n    if force:\n        # We're forcing the representation to be a secret\n        # We do this for consistency\n        return f\"{word[0:1]}...{word[-1:]}\"\n\n    elif len(word) > 1 and not is_hostname(\n        word, ipv4=True, ipv6=True, underscore=False\n    ):\n        # Verify if it is a hostname or not\n        return f\"{word[0:1]}...{word[-1:]}\"\n\n    elif len(word) >= 16:\n        # an IP will be 15 characters so we don't want to use a smaller\n        # value then 16 (e.g 101.102.103.104)\n        # we can assume very long words are passwords otherwise\n        return f\"{word[0:1]}...{word[-1:]}\"\n\n    if advanced:\n        #\n        # Mark word a secret based on it's obscurity\n        #\n\n        # Our variances will increase depending on these variables:\n        last_variance = None\n        obscurity = 0\n\n        for c in word:\n            # Detect our variance\n            if c.isdigit():\n                variance = Variance.NUMERIC\n            elif c.isalpha() and c.isupper():\n                variance = Variance.ALPHA_UPPER\n            elif c.isalpha() and c.islower():\n                variance = Variance.ALPHA_LOWER\n            else:\n                variance = Variance.SPECIAL\n\n            if last_variance != variance or variance == Variance.SPECIAL:\n                obscurity += 1\n\n                if obscurity >= threshold:\n                    return f\"{word[0:1]}...{word[-1:]}\"\n\n            last_variance = variance\n\n    # Otherwise we're good; return our word\n    return word\n\n\ndef cwe312_url(url):\n    \"\"\"This function was written to help mask secure/private information that\n    may or may not be found on an Apprise URL. The idea is to not disrupt the\n    structure of the previous URL too much, yet still protect the users private\n    information from being logged directly to screen.\n\n    For more detail see CWE-312 @\n    https://cwe.mitre.org/data/definitions/312.html\n\n    For example, consider the URL: http://user:password@localhost/\n\n    When passed into this function, the return value would be:\n    http://user:****@localhost/\n\n    Since apprise allows you to put private information everywhere in it's\n    custom URLs, it uses this function to manipulate the content before\n    returning to any kind of logger.\n\n    The idea is that the URL can still be interpreted by the person who\n    constructed them, but not to an intruder.\n    \"\"\"\n    # Parse our URL\n    results = parse_url(url)\n    if not results:\n        # Nothing was returned (invalid data was fed in); return our\n        # information as it was fed to us (without changing it)\n        return url\n\n    # Update our URL with values\n    results[\"password\"] = cwe312_word(results[\"password\"], force=True)\n    if not results[\"schema\"].startswith(\"http\"):\n        results[\"user\"] = cwe312_word(results[\"user\"])\n        results[\"host\"] = cwe312_word(results[\"host\"])\n\n    else:\n        results[\"host\"] = cwe312_word(results[\"host\"], advanced=False)\n        results[\"user\"] = cwe312_word(results[\"user\"], advanced=False)\n\n    # Apply our full path scan in all cases\n    results[\"fullpath\"] = (\n        \"/\"\n        + \"/\".join([\n            cwe312_word(x)\n            for x in re.split(r\"[\\\\/]+\", results[\"fullpath\"].lstrip(\"/\"))\n        ])\n        if results[\"fullpath\"]\n        else \"\"\n    )\n\n    #\n    # Now re-assemble our URL for display purposes\n    #\n\n    # Determine Authentication\n    auth = \"\"\n    if results[\"user\"] and results[\"password\"]:\n        auth = \"{user}:{password}@\".format(\n            user=results[\"user\"],\n            password=results[\"password\"],\n        )\n    elif results[\"user\"]:\n        auth = \"{user}@\".format(\n            user=results[\"user\"],\n        )\n\n    params = \"\"\n    if results[\"qsd\"]:\n        params = \"?{}\".format(\n            \"&\".join([\n                \"{}={}\".format(\n                    k,\n                    cwe312_word(\n                        v,\n                        force=(\n                            k\n                            in (\n                                \"password\",\n                                \"secret\",\n                                \"pass\",\n                                \"token\",\n                                \"key\",\n                                \"id\",\n                                \"apikey\",\n                                \"to\",\n                            )\n                        ),\n                    ),\n                )\n                for k, v in results[\"qsd\"].items()\n            ])\n        )\n\n    return \"{schema}://{auth}{hostname}{port}{fullpath}{params}\".format(\n        schema=results[\"schema\"],\n        auth=auth,\n        # never encode hostname since we're expecting it to be a valid one\n        hostname=results[\"host\"],\n        port=\"\" if not results[\"port\"] else \":{}\".format(results[\"port\"]),\n        fullpath=results[\"fullpath\"] if results[\"fullpath\"] else \"\",\n        params=params,\n    )\n"
  },
  {
    "path": "apprise/utils/disk.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport os\nfrom os.path import expanduser\nimport platform\nimport re\n\nfrom ..logger import logger\n\n# Pre-Escape content since we reference it so much\nESCAPED_PATH_SEPARATOR = re.escape(\"\\\\/\")\nESCAPED_WIN_PATH_SEPARATOR = re.escape(\"\\\\\")\nESCAPED_NUX_PATH_SEPARATOR = re.escape(\"/\")\n\nTIDY_WIN_PATH_RE = re.compile(\n    rf\"(^[{ESCAPED_WIN_PATH_SEPARATOR}]{{2}}|[^{ESCAPED_WIN_PATH_SEPARATOR}\\s][{ESCAPED_WIN_PATH_SEPARATOR}]|[\\s][{ESCAPED_WIN_PATH_SEPARATOR}]{{2}}])([{ESCAPED_WIN_PATH_SEPARATOR}]+)\",\n)\nTIDY_WIN_TRIM_RE = re.compile(\n    rf\"^(.+[^:][^{ESCAPED_WIN_PATH_SEPARATOR}])[\\s{ESCAPED_WIN_PATH_SEPARATOR}]*$\",\n)\n\nTIDY_NUX_PATH_RE = re.compile(\n    rf\"([{ESCAPED_NUX_PATH_SEPARATOR}])([{ESCAPED_NUX_PATH_SEPARATOR}]+)\",\n)\n\n# A simple path decoder we can re-use which looks after\n# ensuring our file info is expanded correctly when provided\n# a path.\n__PATH_DECODER = (\n    os.path.expandvars\n    if platform.system() == \"Windows\"\n    else os.path.expanduser\n)\n\n\ndef path_decode(path):\n    \"\"\"Returns the fully decoded path based on the operating system.\"\"\"\n    return os.path.abspath(__PATH_DECODER(path))\n\n\ndef tidy_path(path):\n    \"\"\"Take a filename and or directory and attempts to tidy it up by removing\n    trailing slashes and correcting any formatting issues.\n\n    For example: ////absolute//path// becomes:\n        /absolute/path\n    \"\"\"\n    # Windows\n    path = TIDY_WIN_PATH_RE.sub(\"\\\\1\", path.strip())\n    # Linux\n    path = TIDY_NUX_PATH_RE.sub(\"\\\\1\", path)\n\n    # Windows Based (final) Trim\n    path = expanduser(TIDY_WIN_TRIM_RE.sub(\"\\\\1\", path))\n    return path\n\n\ndef dir_size(path, max_depth=3, missing_okay=True, _depth=0, _errors=None):\n    \"\"\"Scans a provided path an returns it's size (in bytes) of path\n    provided.\"\"\"\n\n    if _errors is None:\n        _errors = set()\n\n    if _depth > max_depth:\n        _errors.add(path)\n        return (0, _errors)\n\n    total = 0\n    try:\n        with os.scandir(path) as it:\n            for entry in it:\n                try:\n                    if entry.is_file(follow_symlinks=False):\n                        total += entry.stat(follow_symlinks=False).st_size\n\n                    elif entry.is_dir(follow_symlinks=False):\n                        (totals, _) = dir_size(\n                            entry.path,\n                            max_depth=max_depth,\n                            _depth=_depth + 1,\n                            _errors=_errors,\n                        )\n                        total += totals\n\n                except FileNotFoundError:\n                    # no worries; Nothing to do\n                    continue\n\n                except OSError as e:\n                    # Permission error of some kind or disk problem...\n                    # There is nothing we can do at this point\n                    _errors.add(entry.path)\n                    logger.warning(\n                        \"dir_size detetcted inaccessible path: %s\",\n                        os.fsdecode(entry.path),\n                    )\n                    logger.debug(f\"dir_size Exception: {e!s}\")\n                    continue\n\n    except FileNotFoundError:\n        if not missing_okay:\n            # Conditional error situation\n            _errors.add(path)\n\n    except OSError as e:\n        # Permission error of some kind or disk problem...\n        # There is nothing we can do at this point\n        _errors.add(path)\n        logger.warning(\n            \"dir_size detetcted inaccessible path: %s\", os.fsdecode(path)\n        )\n        logger.debug(f\"dir_size Exception: {e!s}\")\n\n    return (total, _errors)\n\n\ndef bytes_to_str(value):\n    \"\"\"Covert an integer (in bytes) into it's string representation with\n    acompanied unit value (such as B, KB, MB, GB, TB, etc)\"\"\"\n    unit = \"B\"\n    try:\n        value = float(value)\n\n    except (ValueError, TypeError):\n        return None\n\n    if value >= 1024.0:\n        value = value / 1024.0\n        unit = \"KB\"\n        if value >= 1024.0:\n            value = value / 1024.0\n            unit = \"MB\"\n            if value >= 1024.0:\n                value = value / 1024.0\n                unit = \"GB\"\n                if value >= 1024.0:\n                    value = value / 1024.0\n                    unit = \"TB\"\n\n    return f\"{round(value, 2):.2f}{unit}\"\n"
  },
  {
    "path": "apprise/utils/format.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom __future__ import annotations\n\nimport re\n\nfrom apprise.common import NotifyFormat\n\n# Characters we can apply a new line to if found\nPUNCTUATION_CHARS = \".!?:;\"\nPUNCT_SPLIT_PATTERN = re.compile(\n    f\"[{re.escape(PUNCTUATION_CHARS)}][ \\t\\r\\n\\x0b\\x0c]+\"\n)\n\n# Support HTML entities (&...;)\nHTML_ENTITY_LOOKBACK = 16\nHTML_ENTITY_LOOKAHEAD = 16\n\n# Support Markdown constructs (e.g., links, formatting)\n# Longer lookback for links [text](url)\nMARKDOWN_CONSTRUCT_LOOKBACK = 32\n\n\ndef html_adjust(\n    text: str,\n    window_start: int,\n    split_at: int,\n) -> int:\n    \"\"\"\n    Adjust the split point to avoid splitting inside short HTML entities\n    such as '&nbsp;'.\n\n    If the split falls inside '&...;' within a small window around the\n    boundary, move the split back to '&' so the entire entity is kept\n    in the next chunk.\n    \"\"\"\n    if split_at <= window_start or split_at > len(text):\n        return split_at\n\n    search_start = max(window_start, split_at - HTML_ENTITY_LOOKBACK)\n    search_end = split_at\n\n    amp_index = text.rfind(\"&\", search_start, search_end)\n    if amp_index == -1:\n        return split_at\n\n    forward_end = min(len(text), split_at + HTML_ENTITY_LOOKAHEAD)\n    semi_index = text.find(\";\", amp_index, forward_end)\n\n    if (\n        semi_index != -1\n        and amp_index > window_start\n        and amp_index < split_at <= semi_index\n    ):\n        return amp_index\n\n    return split_at\n\n\ndef markdown_adjust(\n    text: str,\n    window_start: int,\n    split_at: int,\n) -> int:\n    \"\"\"\n    Adjust the split point to avoid splitting inside simple Markdown\n    link / image constructs like [Text](URL) or ![Alt](URL).\n\n    This is a best-effort heuristic and does not attempt full Markdown\n    parsing. If the boundary falls between '['/'!' and the closing ')'\n    of a nearby link/image, move the split back to that start.\n    \"\"\"\n    if split_at <= window_start or split_at > len(text):\n        return split_at\n\n    search_start = max(window_start, split_at - MARKDOWN_CONSTRUCT_LOOKBACK)\n\n    # Prefer '[' as the starting marker for links/images.\n    link_start_idx = text.rfind(\"[\", search_start, split_at)\n    if link_start_idx == -1:\n        # As a fallback, consider '!' as a possible start, e.g. '![Alt](...)'.\n        link_start_idx = text.rfind(\"!\", search_start, split_at)\n\n    if link_start_idx == -1:\n        return split_at\n\n    # Look ahead for a closing ')' to bound the construct.\n    forward_end = min(len(text), split_at + MARKDOWN_CONSTRUCT_LOOKBACK)\n    link_end_idx = text.find(\")\", link_start_idx, forward_end)\n\n    if link_end_idx != -1 and link_start_idx < split_at < link_end_idx:\n        return link_start_idx\n\n    return split_at\n\n\ndef smart_split(\n    text: str,\n    limit: int,\n    body_format: NotifyFormat,\n) -> list[str]:\n    \"\"\"\n    Split `text` into chunks of at most `limit` characters.\n\n    Soft split priority:\n      1. Last newline before `limit` (\\\\n or \\\\r)\n      2. Last space or tab before `limit`\n      3. Last punctuation+whitespace (.,!?:; followed by space/tab/newline)\n      4. Hard split at `limit`\n\n    `body_format` controls additional safety rules:\n      - NotifyFormat.TEXT: generic splitting only\n      - NotifyFormat.HTML: avoid splitting inside '&...;' entities\n      - NotifyFormat.MARKDOWN: same as HTML, plus a best-effort check to\n        avoid splitting inside [Text](URL) / ![Alt](URL) patterns.\n    \"\"\"\n\n    if not text or limit <= 0:\n        return [\"\"]\n\n    result: list[str] = []\n    start = 0\n    length = len(text)\n\n    while start < length:   # pragma: no branch\n        remaining = length - start\n        if remaining <= limit:\n            result.append(text[start:])\n            break\n\n        window_end = min(start + limit, length)\n        #\n        # Priority 1: Search for newline\n        #\n        last_nl_idx = max(\n            text.rfind(\"\\n\", start, window_end),\n            text.rfind(\"\\r\", start, window_end),\n        )\n        split_nl = last_nl_idx + 1 if last_nl_idx != -1 else -1\n\n        #\n        # Priority 2: Search for ending Space and/or Tab\n        #\n        last_space_tab_idx = max(\n            text.rfind(\" \", start, window_end),\n            text.rfind(\"\\t\", start, window_end),\n        )\n        split_space_tab = (\n            last_space_tab_idx + 1 if last_space_tab_idx != -1 else -1\n        )\n\n        #\n        # Priority 3: Last punctuation + whitespace\n        #\n        split_punct = -1\n        for match in PUNCT_SPLIT_PATTERN.finditer(text, start, window_end):\n            split_punct = match.end()\n\n        # Determine the best soft split point\n        if split_nl != -1:\n            split_at = split_nl\n\n        elif split_space_tab != -1:\n            split_at = split_space_tab\n\n        elif split_punct != -1:\n            split_at = split_punct\n\n        else:\n            #\n            # Priority 4: Hard split (old way of doing things)\n            #\n            split_at = window_end\n\n        #\n        # Conditional Content-specific adjustments\n        #\n        orig_split = split_at\n        if body_format is NotifyFormat.HTML:\n            split_at = html_adjust(text, start, split_at)\n\n        elif body_format is NotifyFormat.MARKDOWN:\n            # Markdown may also contain HTML entities.\n            split_at = html_adjust(text, start, split_at)\n            split_at = markdown_adjust(text, start, split_at)\n\n        if split_at <= start:\n            split_at = orig_split\n\n        result.append(text[start:split_at])\n        start = split_at\n\n    return result\n"
  },
  {
    "path": "apprise/utils/json.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport base64\nfrom datetime import datetime\nimport json\n\nfrom ..common import AWARE_DATE_ISO_FORMAT, NAIVE_DATE_ISO_FORMAT\nfrom ..locale import LazyTranslation\n\n\nclass AppriseJSONEncoder(json.JSONEncoder):\n    \"\"\"A JSON Encoder for handling Apprise internals.\"\"\"\n\n    def default(self, entry):\n        if isinstance(entry, datetime):\n            return entry.strftime(\n                AWARE_DATE_ISO_FORMAT\n                if entry.tzinfo is not None\n                else NAIVE_DATE_ISO_FORMAT\n            )\n\n        elif isinstance(entry, bytes):\n            return base64.b64encode(entry).decode(\"utf-8\")\n\n        elif isinstance(entry, (set, frozenset, tuple)):\n            return list(entry)\n\n        elif isinstance(entry, LazyTranslation):\n            return str(entry)\n\n        return super().default(entry)\n"
  },
  {
    "path": "apprise/utils/logic.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nfrom itertools import chain\n\nfrom .. import common\nfrom .parse import parse_list\n\n\ndef is_exclusive_match(\n    logic,\n    data,\n    match_all=common.MATCH_ALL_TAG,\n    match_always=common.MATCH_ALWAYS_TAG,\n):\n    \"\"\"The data variable should always be a set of strings that the logic can\n    be compared against. It should be a set.  If it isn't already, then it will\n    be converted as such. These identify the tags themselves.\n\n    Our logic should be a list as well:\n      - top level entries are treated as an 'or'\n      - second level (or more) entries are treated as 'and'\n\n      examples:\n        logic=\"tagA, tagB\"                = tagA or tagB\n        logic=['tagA', 'tagB']            = tagA or tagB\n        logic=[('tagA', 'tagC'), 'tagB']  = (tagA and tagC) or tagB\n        logic=[('tagB', 'tagC')]          = tagB and tagC\n\n    If `match_always` is not set to None, then its value is added as an 'or'\n    to all specified logic searches.\n    \"\"\"\n\n    if isinstance(logic, str):\n        # Update our logic to support our delimiters\n        logic = set(parse_list(logic))\n\n    if not logic:\n        # If there is no logic to apply then we're done early; we only match\n        # if there is also no data to match against\n        return not data\n\n    if not isinstance(logic, (list, tuple, set)):\n        # garbage input\n        return False\n\n    if match_always:\n        # Add our match_always to our logic searching if secified\n        logic = chain(logic, [match_always])\n\n    # Track what we match against; but by default we do not match\n    # against anything\n    matched = False\n\n    # Every entry here will be or'ed with the next\n    for entry in logic:\n        if not isinstance(entry, (str, list, tuple, set)):\n            # Garbage entry in our logic found\n            return False\n\n        # treat these entries as though all elements found\n        # must exist in the notification service\n        entries = set(parse_list(entry))\n        if not entries:\n            # We got a bogus set of tags to parse\n            # If there is no logic to apply then we're done early; we only\n            # match if there is also no data to match against\n            return not data\n\n        if len(entries.intersection(data.union({match_all}))) == len(entries):\n            # our set contains all of the entries found\n            # in our notification data set\n            matched = True\n            break\n\n        # else: keep looking\n\n    # Return True if we matched against our logic (or simply none was\n    # specified).\n    return matched\n\n\ndef dict_full_update(dict1, dict2):\n    \"\"\"Takes 2 dictionaries (dict1 and dict2) that contain sub-dictionaries and\n    gracefully merges them into dict1.\n\n    This is similar to: dict1.update(dict2) except that internal dictionaries\n    are also recursively applied.\n    \"\"\"\n\n    def _merge(dict1, dict2):\n        for k in dict2:\n            if (\n                k in dict1\n                and isinstance(dict1[k], dict)\n                and isinstance(dict2[k], dict)\n            ):\n                _merge(dict1[k], dict2[k])\n            else:\n                dict1[k] = dict2[k]\n\n    _merge(dict1, dict2)\n    return\n"
  },
  {
    "path": "apprise/utils/module.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport contextlib\nimport importlib.util\nimport sys\n\nfrom ..logger import logger\n\n\ndef import_module(path, name):\n    \"\"\"Load our module based on path.\"\"\"\n    spec = importlib.util.spec_from_file_location(name, path)\n    try:\n        module = importlib.util.module_from_spec(spec)\n        sys.modules[name] = module\n\n        spec.loader.exec_module(module)\n\n    except Exception as e:\n        # module isn't loadable\n        with contextlib.suppress(KeyError):\n            del sys.modules[name]\n\n        module = None\n\n        logger.debug(\n            \"Module exception raised from %s (name=%s) %s\", path, name, str(e)\n        )\n\n    return module\n"
  },
  {
    "path": "apprise/utils/parse.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport contextlib\nfrom functools import reduce\nimport re\nfrom urllib.parse import quote, unquote, urlencode as _urlencode, urlparse\n\nfrom .disk import tidy_path\n\n# URL Indexing Table for returns via parse_url()\n# The below accepts and scans for:\n#  - schema://\n#  - schema://path\n#  - schema://path?kwargs\n#\nVALID_URL_RE = re.compile(\n    r\"^[\\s]*((?P<schema>[^:\\s]+):[/\\\\]+)?((?P<path>[^?]+)\"\n    r\"(\\?(?P<kwargs>.+))?)?[\\s]*$\",\n)\nVALID_QUERY_RE = re.compile(r\"^(?P<path>.*[/\\\\])(?P<query>[^/\\\\]+)?$\")\n\n# delimiters used to separate values when content is passed in by string.\n# This is useful when turning a string into a list\nSTRING_DELIMITERS = r\"[\\[\\]\\;,\\s]+\"\n\n# String Delimiters without the whitespace\nSTRING_DELIMITERS_NO_WS = r\"[\\[\\]\\;,]+\"\n\n# The handling of custom arguments passed in the URL; we treat any\n# argument (which would otherwise appear in the qsd area of our parse_url()\n# function differently if they start with a +, - or : value\nNOTIFY_CUSTOM_ADD_TOKENS = re.compile(r\"^( |\\+)(?P<key>.*)\\s*\")\nNOTIFY_CUSTOM_DEL_TOKENS = re.compile(r\"^-(?P<key>.*)\\s*\")\nNOTIFY_CUSTOM_COLON_TOKENS = re.compile(r\"^:(?P<key>.*)\\s*\")\n\n# Used for attempting to acquire the schema if the URL can't be parsed.\nGET_SCHEMA_RE = re.compile(r\"\\s*(?P<schema>[a-z0-9]{1,32})://.*$\", re.I)\n\n# Used for validating that a provided entry is indeed a schema\n# this is slightly different then the GET_SCHEMA_RE above which\n# insists the schema is only valid with a :// entry.  this one\n# extrapolates the individual entries\nURL_DETAILS_RE = re.compile(\n    r\"\\s*(?P<schema>[a-z0-9]{1,32})(://(?P<base>.*))?$\", re.I\n)\n\n# Regular expression based and expanded from:\n# http://www.regular-expressions.info/email.html\n# Extended to support colon (:) delimiter for parsing names from the URL\n# such as:\n#   - 'Optional Name':user@example.com\n#   - 'Optional Name' <user@example.com>\n#\n# The expression also parses the general email as well such as:\n#   - user@example.com\n#   - label+user@example.com\nGET_EMAIL_RE = re.compile(\n    r'(([\\s\"\\']+)?(?P<name>[^:<\\'\"]+)?[:<\\s\\'\"]+)?'\n    r\"(?P<full_email>((?P<label>[^+]+)\\+)?\"\n    r\"(?P<email>(?P<userid>[a-z0-9_!#$%&*/=?%`{|}~^-]+\"\n    r\"(?:\\.[a-z0-9_!#$%&\\'*/=?%`{|}~^-]+)\"\n    r\"*)@(?P<domain>(\"\n    r\"(?:[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?\\.)+\"\n    r\"[a-z0-9](?:[a-z0-9_-]*[a-z0-9]))|\"\n    r\"[a-z0-9][a-z0-9_-]{5,})))\"\n    r\"\\s*>?\",\n    re.IGNORECASE,\n)\n\n# A simple verification check to make sure the content specified\n# rougly conforms to a phone number before we parse it further\nIS_PHONE_NO = re.compile(r\"^\\+?(?P<phone>[0-9\\s)(+-]+)\\s*$\")\n\n# Regular expression used to destinguish between multiple phone numbers\nPHONE_NO_DETECTION_RE = re.compile(\n    r\"\\s*([+(\\s]*[0-9][0-9()\\s-]+[0-9])(?=$|[\\s,+(]+[0-9])\", re.I\n)\n\n# Support for prefix: (string followed by colon) infront of phone no\nPHONE_NO_WPREFIX_DETECTION_RE = re.compile(\n    r\"\\s*((?:[a-z]+:)?[+(\\s]*[0-9][0-9()\\s-]+[0-9])\"\n    r\"(?=$|(?:[a-z]+:)?[\\s,+(]+[0-9])\",\n    re.I,\n)\n\n# Quick sanity check - does this look like a valid callsign before we\n# bother parsing it\nIS_CALL_SIGN = re.compile(\n    r\"^(?P<callsign>[0-9a-z]{1,2}[0-9][a-z0-9]{1,3})\"\n    r\"(-(?P<ssid>[0-9]{1,2}))?\\s*$\",\n    re.I,\n)\n\n# Regex to split multiple callsigns from a single string\nCALL_SIGN_DETECTION_RE = re.compile(\n    r\"\\s*([0-9a-z]{1,2}[0-9][a-z0-9]{1,3}(?:-(?:[0-9]{1,2}))?)\"\n    r\"(?=\\s*$|\\s*[,;\\s]+)\",\n    re.I,\n)\n\n# Regular expression used to destinguish between multiple URLs\nURL_DETECTION_RE = re.compile(\n    r\"([a-z0-9]+?:\\/\\/.*?)(?=$|[\\s,]+[a-z0-9]{1,32}?:\\/\\/)\", re.I\n)\n\nEMAIL_DETECTION_RE = re.compile(\n    r\"[\\s,]*([^@]+@.*?)(?=$|[\\s,]+\"\n    r\"(?:[^:<]+?[:<\\s]+?)?\"\n    r\"[^@\\s,]+@[^\\s,]+)\",\n    re.IGNORECASE,\n)\n\n# Used to prepare our UUID regex matching\nUUID4_RE = re.compile(\n    r\"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\",\n    re.IGNORECASE,\n)\n\n# Validate if we're a loadable Python file or not\nVALID_PYTHON_FILE_RE = re.compile(r\".+\\.py(o|c)?$\", re.IGNORECASE)\n\n# validate_regex() utilizes this mapping to track and re-use pre-complied\n# regular expressions\nREGEX_VALIDATE_LOOKUP = {}\n\n\ndef is_ipaddr(addr, ipv4=True, ipv6=True):\n    \"\"\"Validates against IPV4 and IPV6 IP Addresses.\"\"\"\n\n    if ipv4:\n        # Based on https://stackoverflow.com/questions/5284147/\\\n        #       validating-ipv4-addresses-with-regexp\n        re_ipv4 = re.compile(\n            r\"^(?P<ip>((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}\"\n            r\"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))$\"\n        )\n        match = re_ipv4.match(addr)\n        if match is not None:\n            # Return our matched IP\n            return match.group(\"ip\")\n\n    if ipv6:\n        # Based on https://stackoverflow.com/questions/53497/\\\n        #              regular-expression-that-matches-valid-ipv6-addresses\n        #\n        # IPV6 URLs should be enclosed in square brackets when placed on a URL\n        #   Source: https://tools.ietf.org/html/rfc2732\n        #   - For this reason, they are additionally checked for existance\n        re_ipv6 = re.compile(\n            r\"\\[?(?P<ip>(([0-9a-f]{1,4}:){7,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)\"\n            r\"{1,7}:|([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,5}\"\n            r\"(:[0-9a-f]{1,4}){1,2}|([0-9a-f]{1,4}:){1,4}\"\n            r\"(:[0-9a-f]{1,4}){1,3}|([0-9a-f]{1,4}:){1,3}\"\n            r\"(:[0-9a-f]{1,4}){1,4}|([0-9a-f]{1,4}:){1,2}\"\n            r\"(:[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:\"\n            r\"((:[0-9a-f]{1,4}){1,6})|:((:[0-9a-f]{1,4}){1,7}|:)|\"\n            r\"fe80:(:[0-9a-f]{0,4}){0,4}%[0-9a-z]{1,}|::\"\n            r\"(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]\"\n            r\"|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|\"\n            r\"1{0,1}[0-9]){0,1}[0-9])|([0-9a-f]{1,4}:){1,4}:((25[0-5]|\"\n            r\"(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|\"\n            r\"1{0,1}[0-9]){0,1}[0-9])))\\]?\",\n            re.I,\n        )\n\n        match = re_ipv6.match(addr)\n        if match is not None:\n            # Return our matched IP between square brackets since that is\n            # required for URL formatting as per RFC 2732.\n            return \"[{}]\".format(match.group(\"ip\"))\n\n    # There was no match\n    return False\n\n\ndef is_hostname(hostname, ipv4=True, ipv6=True, underscore=True):\n    \"\"\"Validate hostname.\"\"\"\n    # The entire hostname, including the delimiting dots, has a maximum of 253\n    # ASCII characters.\n    if len(hostname) > 253 or len(hostname) == 0:\n        return False\n\n    # Strip trailing period on hostname (if one exists)\n    if hostname[-1] == \".\":\n        hostname = hostname[:-1]\n\n    # Split our hostname up\n    labels = hostname.split(\".\")\n\n    # ipv4 check\n    if len(labels) == 4 and re.match(r\"^[0-9.]+$\", hostname):\n        return is_ipaddr(hostname, ipv4=ipv4, ipv6=False)\n\n    # - RFC 1123 permits hostname labels to start with digits\n    #     - digit must be followed by alpha/numeric so we don't end up\n    #       processing IP addresses here\n    # - Hostnames can ony be comprised of alpha-numeric characters and the\n    #   hyphen (-) character.\n    # - Hostnames can not start with the hyphen (-) character.\n    # - as a workaround for https://github.com/docker/compose/issues/229 to\n    #   being able to address services in other stacks, we also allow\n    #   underscores in hostnames (if flag is set accordingly)\n    # - labels can not exceed 63 characters\n    # - allow single character alpha characters\n    allowed = re.compile(\n        (\n            r\"^([a-z0-9][a-z0-9_-]{1,62}|[a-z_-])(?<![_-])$\"\n            if underscore\n            else r\"^([a-z0-9][a-z0-9-]{1,62}|[a-z-])(?<!-)$\"\n        ),\n        re.IGNORECASE,\n    )\n\n    if not all(allowed.match(x) for x in labels):\n        return is_ipaddr(hostname, ipv4=ipv4, ipv6=ipv6)\n\n    return hostname\n\n\ndef is_uuid(uuid):\n    \"\"\"Determine if the specified entry is uuid v4 string.\n\n    Args:\n        address (str): The string you want to check.\n\n    Returns:\n        bool: Returns False if the specified element is not a uuid otherwise\n              it returns True\n    \"\"\"\n\n    try:\n        match = UUID4_RE.match(uuid)\n\n    except TypeError:\n        # not parseable content\n        return False\n\n    return bool(match)\n\n\ndef is_phone_no(phone, min_len=10):\n    \"\"\"Determine if the specified entry is a phone number.\n\n    Args:\n        phone (str): The string you want to check.\n        min_len (int): Defines the smallest expected length of the phone\n                       before it's to be considered invalid. By default\n                       the phone number can't be any larger then 14\n\n    Returns:\n        bool: Returns False if the address specified is not a phone number\n              and a dictionary of the parsed phone number if it is as:\n                {\n                    'country': '1',\n                    'area': '800',\n                    'line': '1234567',\n                    'full': '18001234567',\n                    'pretty': '+1 800-123-4567',\n                }\n\n        Non conventional numbers such as 411 would look like provided that\n        `min_len` is set to at least a 3:\n                {\n                    'country': '',\n                    'area': '',\n                    'line': '411',\n                    'full': '411',\n                    'pretty': '411',\n                }\n    \"\"\"\n\n    try:\n        if not IS_PHONE_NO.match(phone):\n            # not parseable content as it does not even conform closely to a\n            # phone number)\n            return False\n\n    except TypeError:\n        return False\n\n    # Tidy phone number up first\n    phone = re.sub(r\"[^\\d]+\", \"\", phone)\n    if len(phone) > 14 or len(phone) < min_len:\n        # Invalid phone number\n        return False\n\n    # Full phone number without any markup is as is now\n    full = phone\n\n    # Break apart our phone number\n    line = phone[-7:]\n    phone = phone[: len(phone) - 7] if len(phone) > 7 else \"\"\n\n    # the area code (if present)\n    area = phone[-3:] if phone else \"\"\n\n    # The country code is the leftovers\n    country = phone[: len(phone) - 3] if len(phone) > 3 else \"\"\n\n    # Prepare a nicely (consistently) formatted phone number\n    pretty = \"\"\n\n    if country:\n        # The leftover is the country code\n        pretty += f\"+{country} \"\n\n    if area:\n        pretty += f\"{area}-\"\n\n    if len(line) >= 7:\n        pretty += f\"{line[:3]}-{line[3:]}\"\n\n    else:\n        pretty += line\n\n    return {\n        # The line code (last 7 digits)\n        \"line\": line,\n        # Area code\n        \"area\": area,\n        # The country code (if identified)\n        \"country\": country,\n        # A nicely formatted phone no\n        \"pretty\": pretty,\n        # All digits in-line\n        \"full\": full,\n    }\n\n\ndef is_call_sign(callsign):\n    \"\"\"Determine if the specified entry is a ham radio call sign.\n\n    Args:\n        callsign (str): The string you want to check.\n\n    Returns:\n        bool: Returns False if the address specified is not a phone number\n    \"\"\"\n\n    try:\n        result = IS_CALL_SIGN.match(callsign)\n        if not result:\n            # not parseable content as it does not even conform closely to a\n            # callsign\n            return False\n\n    except TypeError:\n        # not parseable content\n        return False\n\n    ssid = result.group(\"ssid\")\n    return {\n        # always treat call signs as uppercase content\n        \"callsign\": result.group(\"callsign\").upper(),\n        # Prevent the storing of the None keyword in the event the SSID was\n        # not detected\n        \"ssid\": ssid if ssid else \"\",\n    }\n\n\ndef is_email(address):\n    \"\"\"Determine if the specified entry is an email address.\n\n    Args:\n        address (str): The string you want to check.\n\n    Returns:\n        bool: Returns False if the address specified is not an email address\n              and a dictionary of the parsed email if it is as:\n                {\n                    'name': 'Parse Name'\n                    'email': 'user@domain.com'\n                    'full_email': 'label+user@domain.com'\n                    'label': 'label'\n                    'user': 'user',\n                    'domain': 'domain.com'\n                }\n    \"\"\"\n\n    try:\n        match = GET_EMAIL_RE.match(address)\n\n    except TypeError:\n        # not parseable content\n        return False\n\n    if match:\n        return {\n            # The name parsed from the URL (if one exists)\n            \"name\": (\n                \"\"\n                if match.group(\"name\") is None\n                else match.group(\"name\").rstrip(\" \\t\\r\\n\\x0b\\x0c+\")\n            ),\n            # The email address\n            \"email\": match.group(\"email\"),\n            # The full email address (includes label if specified)\n            \"full_email\": match.group(\"full_email\"),\n            # The label (if specified) e.g: label+user@example.com\n            \"label\": (\n                \"\"\n                if match.group(\"label\") is None\n                else match.group(\"label\").strip(\" \\t\\r\\n\\x0b\\x0c+\")\n            ),\n            # The user (which does not include the label) from the email\n            # parsed.\n            \"user\": match.group(\"userid\"),\n            # The domain associated with the email address\n            \"domain\": match.group(\"domain\"),\n        }\n\n    return False\n\n\ndef parse_qsd(qs, simple=False, plus_to_space=False, sanitize=True):\n    \"\"\"Query String Dictionary Builder.\n\n    A custom implimentation of the parse_qsl() function already provided by\n    Python.  This function is slightly more light weight and gives us more\n    control over parsing out arguments such as the plus/+ symbol at the head of\n    a key/value pair.\n\n    qs should be a query string part made up as part of the URL such as\n    a=1&c=2&d=\n\n    a=1 gets interpreted as { 'a': '1' } a=  gets interpreted as { 'a': '' } a\n    gets interpreted as { 'a': '' }\n\n     This function returns a result object that fits with the apprise expected\n    parameters (populating the 'qsd' portion of the dictionary\n\n    if simple is set to true, then a ONE dictionary is returned and is not sub-\n    parsed for additional elements\n\n    plus_to_space will cause all `+` references to become a space as per normal\n    URL Encoded defininition. Normal URL parsing applies this, but `+` is very\n    actively used character with passwords, api keys, tokens, etc.  So Apprise\n    does not do this by default.\n\n    if sanitize is set to False, then kwargs are not placed into lowercase\n    \"\"\"\n\n    # Our return result set:\n    result = (\n        {\n            # The arguments passed in (the parsed query). This is in a\n            # dictionary of {'key': 'val', etc }.  Keys are all made lowercase\n            # before storing to simplify access to them.\n            \"qsd\": {},\n            # Detected Entries that start with + or - are additionally stored\n            # in these values (un-touched).  The :,+,- however are stripped\n            # from their name before they are stored here.\n            \"qsd+\": {},\n            \"qsd-\": {},\n            \"qsd:\": {},\n        }\n        if not simple\n        else {\"qsd\": {}}\n    )\n\n    pairs = [s2 for s1 in qs.split(\"&\") for s2 in s1.split(\";\")]\n    for name_value in pairs:\n        nv = name_value.split(\"=\", 1)\n        # Handle case of a control-name with no equal sign\n        if len(nv) != 2:\n            nv.append(\"\")\n\n        # Apprise keys can start with a + symbol; so we need to skip over\n        # the very first entry\n        key = \"{}{}\".format(\n            \"\" if len(nv[0]) == 0 else nv[0][0],\n            \"\" if len(nv[0]) <= 1 else nv[0][1:].replace(\"+\", \" \"),\n        )\n\n        key = unquote(key)\n        key = key if key else \"\"\n\n        val = nv[1].replace(\"+\", \" \") if plus_to_space else nv[1]\n        val = unquote(val)\n        val = \"\" if not val else val.strip()\n\n        # Always Query String Dictionary (qsd) for every entry we have\n        # content is always made lowercase for easy indexing\n        result[\"qsd\"][key.lower().strip() if sanitize else key] = val\n\n        if simple:\n            # move along\n            continue\n\n        # Check for tokens that start with a addition/plus symbol (+)\n        k = NOTIFY_CUSTOM_ADD_TOKENS.match(key)\n        if k is not None:\n            # Store content 'as-is'\n            result[\"qsd+\"][k.group(\"key\")] = val\n\n        # Check for tokens that start with a subtraction/hyphen symbol (-)\n        k = NOTIFY_CUSTOM_DEL_TOKENS.match(key)\n        if k is not None:\n            # Store content 'as-is'\n            result[\"qsd-\"][k.group(\"key\")] = val\n\n        # Check for tokens that start with a colon symbol (:)\n        k = NOTIFY_CUSTOM_COLON_TOKENS.match(key)\n        if k is not None:\n            # Store content 'as-is'\n            result[\"qsd:\"][k.group(\"key\")] = val\n\n    return result\n\n\ndef parse_url(\n    url,\n    default_schema=\"http\",\n    verify_host=True,\n    strict_port=False,\n    simple=False,\n    plus_to_space=False,\n    sanitize=True,\n):\n    \"\"\"A function that greatly simplifies the parsing of a url specified by the\n    end user.\n\n    Valid syntaxes are:\n       <schema>://<user>@<host>:<port>/<path>\n       <schema>://<user>:<passwd>@<host>:<port>/<path>\n       <schema>://<host>:<port>/<path>\n       <schema>://<host>/<path>\n       <schema>://<host>\n       <host>\n\n    Argument parsing is also supported:\n       <schema>://<user>@<host>:<port>/<path>?key1=val&key2=val2\n       <schema>://<user>:<passwd>@<host>:<port>/<path>?key1=val&key2=val2\n       <schema>://<host>:<port>/<path>?key1=val&key2=val2\n       <schema>://<host>/<path>?key1=val&key2=val2\n       <schema>://<host>?key1=val&key2=val2\n\n    The function returns a simple dictionary with all of\n    the parsed content within it and returns 'None' if the\n    content could not be extracted.\n\n    The output of 'http://hostname' would look like:\n       {\n         'schema': 'http',\n         'url': 'http://hostname',\n         'host': 'hostname',\n\n         'user': None,\n         'password': None,\n         'port': None,\n         'fullpath': None,\n         'path': None,\n         'query': None,\n\n         'qsd': {},\n\n         'qsd+': {},\n         'qsd-': {},\n         'qsd:': {}\n       }\n\n    The simple switch cleans the dictionary response to only include the\n    fields that were detected.\n\n    The output of 'http://hostname' with the simple flag set would look like:\n       {\n         'schema': 'http',\n         'url': 'http://hostname',\n         'host': 'hostname',\n       }\n\n    If the URL can't be parsed then None is returned\n\n    If sanitize is set to False, then kwargs are not placed in lowercase\n    and wrapping whitespace is not removed\n    \"\"\"\n\n    if not isinstance(url, str):\n        # Simple error checking\n        return None\n\n    # Default Results\n    result = (\n        {\n            # The username (if specified)\n            \"user\": None,\n            # The password (if specified)\n            \"password\": None,\n            # The port (if specified)\n            \"port\": None,\n            # The hostname\n            \"host\": \"\",\n            # The full path (query + path)\n            \"fullpath\": None,\n            # The path\n            \"path\": None,\n            # The query\n            \"query\": None,\n            # The schema\n            \"schema\": None,\n            # The schema\n            \"url\": None,\n            # The arguments passed in (the parsed query). This is in a\n            # dictionary of {'key': 'val', etc }.  Keys are all made lowercase\n            # before storing to simplify access to them.\n            # qsd = Query String Dictionary\n            \"qsd\": {},\n            # Detected Entries that start with +, - or : are additionally\n            # stored in these values (un-touched).  The +, -, and : however\n            # are stripped from their name before they are stored here.\n            \"qsd+\": {},\n            \"qsd-\": {},\n            \"qsd:\": {},\n        }\n        if not simple\n        else {}\n    )\n\n    qsdata = \"\"\n    match = VALID_URL_RE.search(url)\n    if match:\n        # Extract basic results (with schema present)\n        result[\"schema\"] = (\n            match.group(\"schema\").lower().strip()\n            if match.group(\"schema\")\n            else default_schema\n        )\n        host = match.group(\"path\").strip() if match.group(\"path\") else \"\"\n        qsdata = (\n            match.group(\"kwargs\").strip() if match.group(\"kwargs\") else None\n        )\n\n    else:\n        # Could not extract basic content from the URL\n        return None\n\n    # Parse Query Arugments ?val=key&key=val\n    # while ensuring that all keys are lowercase\n    if qsdata:\n        result.update(\n            parse_qsd(\n                qsdata,\n                simple=simple,\n                plus_to_space=plus_to_space,\n                sanitize=sanitize,\n            )\n        )\n\n    # Now do a proper extraction of data; http:// is just substitued in place\n    # to allow urlparse() to function as expected, we'll swap this back to the\n    # expected schema after.\n    parsed = urlparse(f\"http://{host}\")\n\n    # Parse results\n    result[\"host\"] = parsed[1].strip()\n    result[\"fullpath\"] = quote(unquote(tidy_path(parsed[2].strip())))\n\n    try:\n        # Handle trailing slashes removed by tidy_path\n        if result[\"fullpath\"][-1] not in (\"/\", \"\\\\\") and url[-1] in (\n            \"/\",\n            \"\\\\\",\n        ):\n            result[\"fullpath\"] += url.strip()[-1]\n\n    except IndexError:\n        # No problem, there simply isn't any returned results\n        # and therefore, no trailing slash\n        pass\n\n    if not result[\"fullpath\"]:\n        if not simple:\n            # Default\n            result[\"fullpath\"] = None\n        else:\n            # Remove entry\n            del result[\"fullpath\"]\n\n    else:\n        # Using full path, extract query from path\n        match = VALID_QUERY_RE.search(result[\"fullpath\"])\n        result[\"path\"] = match.group(\"path\")\n        result[\"query\"] = match.group(\"query\")\n        if not result[\"query\"]:\n            if not simple:\n                result[\"query\"] = None\n            else:\n                del result[\"query\"]\n\n    with contextlib.suppress(ValueError):\n        (result[\"user\"], result[\"host\"]) = re.split(r\"[@]+\", result[\"host\"])[\n            :2\n        ]\n\n    if result.get(\"user\") is not None:\n        with contextlib.suppress(ValueError):\n            (result[\"user\"], result[\"password\"]) = re.split(\n                r\"[:]+\", result[\"user\"]\n            )[:2]\n\n    # Port Parsing\n    pmatch = re.search(\n        r\"^(?P<host>(\\[[0-9a-f:]+\\]|[^:]+)):(?P<port>[^:]*)$\", result[\"host\"]\n    )\n\n    if pmatch:\n        # Separate our port from our hostname (if port is detected)\n        result[\"host\"] = pmatch.group(\"host\")\n        try:\n            # If we're dealing with an integer, go ahead and convert it\n            # otherwise return an 'x' which will raise a ValueError\n            #\n            # This small extra check allows us to treat floats/doubles\n            # as strings. Hence a value like '4.2' won't be converted to a 4\n            # (and the .2 lost)\n            result[\"port\"] = int(\n                pmatch.group(\"port\")\n                if re.search(r\"[0-9]\", pmatch.group(\"port\"))\n                else \"x\"\n            )\n\n        except ValueError:\n            if verify_host:\n                # Invalid Host Specified\n                return None\n\n    # Acquire our port (if defined)\n    port = result.get(\"port\")\n\n    if verify_host:\n        # Verify and Validate our hostname\n        result[\"host\"] = is_hostname(result[\"host\"])\n        if not result[\"host\"]:\n            # Nothing more we can do without a hostname; give the user\n            # some indication as to what went wrong\n            return None\n\n        # Max port is 65535 and min is 1\n        if isinstance(port, int) and not (\n            not strict_port or (strict_port and port > 0 and port <= 65535)\n        ):\n\n            # An invalid port was specified\n            return None\n\n    elif pmatch and not isinstance(port, int):\n        if strict_port:\n            # Store port\n            result[\"port\"] = pmatch.group(\"port\").strip()\n\n        else:\n            # Fall back\n            result[\"port\"] = None\n            result[\"host\"] = f\"{pmatch.group('host')}:{pmatch.group('port')}\"\n\n    # Re-assemble cleaned up version of the url\n    result[\"url\"] = f\"{result['schema']}://\"\n    if isinstance(result.get(\"user\"), str):\n        result[\"url\"] += result[\"user\"]\n\n        if isinstance(result.get(\"password\"), str):\n            result[\"url\"] += f\":{result['password']}@\"\n\n        else:\n            result[\"url\"] += \"@\"\n    result[\"url\"] += result[\"host\"]\n\n    if result.get(\"port\") is not None:\n        result[\"url\"] += f\":{result['port']}\"\n\n    elif \"port\" in result and simple:\n        # Eliminate empty fields\n        del result[\"port\"]\n\n    if result.get(\"fullpath\"):\n        result[\"url\"] += result[\"fullpath\"]\n\n    if simple and not result[\"host\"]:\n        # simple mode does not carry over empty host names\n        del result[\"host\"]\n\n    return result\n\n\ndef parse_bool(arg, default=False):\n    \"\"\"Support string based boolean settings.\n\n    If the content could not be parsed, then the default is returned.\n    \"\"\"\n\n    if isinstance(arg, str):\n        # no = no - False\n        # of = short for off - False\n        # 0  = int for False\n        # fa = short for False - False\n        # f  = short for False - False\n        # n  = short for No or Never - False\n        # ne  = short for Never - False\n        # di  = short for Disable(d) - False\n        # de  = short for Deny - False\n        if arg.lower()[0:2] in (\n            \"de\",\n            \"di\",\n            \"ne\",\n            \"f\",\n            \"n\",\n            \"no\",\n            \"of\",\n            \"0\",\n            \"fa\",\n        ):\n            return False\n        # ye = yes - True\n        # on = short for off - True\n        # 1  = int for True\n        # tr = short for True - True\n        # t  = short for True - True\n        # al = short for Always (and Allow) - True\n        # en  = short for Enable(d) - True\n        elif arg.lower()[0:2] in (\"en\", \"al\", \"t\", \"y\", \"ye\", \"on\", \"1\", \"tr\"):\n            return True\n        # otherwise\n        return default\n\n    # Handle other types\n    return bool(arg)\n\n\ndef parse_phone_no(*args, store_unparseable=True, prefix=False, **kwargs):\n    \"\"\"Takes a string containing phone numbers separated by comma's and/or\n    spaces and returns a list.\"\"\"\n\n    result = []\n    for arg in args:\n        if isinstance(arg, str) and arg:\n            result_ = (\n                PHONE_NO_DETECTION_RE\n                if not prefix\n                else PHONE_NO_WPREFIX_DETECTION_RE\n            ).findall(arg)\n            if result_:\n                result += result_\n\n            elif not result_ and store_unparseable:\n                # we had content passed into us that was lost because it was\n                # so poorly formatted that it didn't even come close to\n                # meeting the regular expression we defined. We intentially\n                # keep it as part of our result set so that parsing done\n                # at a higher level can at least report this to the end user\n                # and hopefully give them some indication as to what they\n                # may have done wrong.\n                result += list(filter(bool, re.split(STRING_DELIMITERS, arg)))\n\n        elif isinstance(arg, (set, list, tuple)):\n            # Use recursion to handle the list of phone numbers\n            result += parse_phone_no(\n                *arg, store_unparseable=store_unparseable, prefix=prefix\n            )\n\n    return result\n\n\ndef parse_call_sign(*args, store_unparseable=True, **kwargs):\n    \"\"\"Takes a string containing ham radio call signs separated by comma and/or\n    spacesand returns a list.\"\"\"\n\n    result = []\n    for arg in args:\n        if isinstance(arg, str) and arg:\n            result_ = CALL_SIGN_DETECTION_RE.findall(arg)\n            if result_:\n                result += result_\n\n            elif not result_ and store_unparseable:\n                # we had content passed into us that was lost because it was\n                # so poorly formatted that it didn't even come close to\n                # meeting the regular expression we defined. We intentially\n                # keep it as part of our result set so that parsing done\n                # at a higher level can at least report this to the end user\n                # and hopefully give them some indication as to what they\n                # may have done wrong.\n                result += list(filter(bool, re.split(STRING_DELIMITERS, arg)))\n\n        elif isinstance(arg, (set, list, tuple)):\n            # Use recursion to handle the list of call signs\n            result += parse_call_sign(\n                *arg, store_unparseable=store_unparseable\n            )\n\n    return result\n\n\ndef parse_emails(*args, store_unparseable=True, **kwargs):\n    \"\"\"Takes a string containing emails separated by comma's and/or spaces and\n    returns a list.\"\"\"\n\n    result = []\n    for arg in args:\n        if isinstance(arg, str) and arg:\n            result_ = EMAIL_DETECTION_RE.findall(arg)\n            if result_:\n                result += result_\n\n            elif not result_ and store_unparseable:\n                # we had content passed into us that was lost because it was\n                # so poorly formatted that it didn't even come close to\n                # meeting the regular expression we defined. We intentially\n                # keep it as part of our result set so that parsing done\n                # at a higher level can at least report this to the end user\n                # and hopefully give them some indication as to what they\n                # may have done wrong.\n                result += list(filter(bool, re.split(STRING_DELIMITERS, arg)))\n\n        elif isinstance(arg, (set, list, tuple)):\n            # Use recursion to handle the list of Emails\n            result += parse_emails(*arg, store_unparseable=store_unparseable)\n\n    return result\n\n\ndef url_assembly(encode=False, **kwargs):\n    \"\"\"This function reverses the parse_url() function by taking in the\n    provided result set and re-assembling a URL.\"\"\"\n\n    def _no_encode(content, *args, **kwargs):\n        # dummy function that does nothing to content\n        return content\n\n    quote_ = quote if encode else _no_encode\n\n    # Determine Authentication\n    auth = \"\"\n    if kwargs.get(\"user\") is not None and kwargs.get(\"password\") is not None:\n\n        auth = \"{user}:{password}@\".format(\n            user=quote_(kwargs.get(\"user\"), safe=\"\"),\n            password=quote_(kwargs.get(\"password\"), safe=\"\"),\n        )\n\n    elif kwargs.get(\"user\") is not None:\n        auth = \"{user}@\".format(\n            user=quote_(kwargs.get(\"user\"), safe=\"\"),\n        )\n\n    return \"{schema}://{auth}{hostname}{port}{fullpath}{params}\".format(\n        schema=\"\" if not kwargs.get(\"schema\") else kwargs.get(\"schema\"),\n        auth=auth,\n        # never encode hostname since we're expecting it to be a valid one\n        hostname=\"\" if not kwargs.get(\"host\") else kwargs.get(\"host\", \"\"),\n        port=(\n            \"\" if not kwargs.get(\"port\") else \":{}\".format(kwargs.get(\"port\"))\n        ),\n        fullpath=quote_(kwargs.get(\"fullpath\", \"\"), safe=\"/\"),\n        params=(\n            \"\"\n            if not kwargs.get(\"qsd\")\n            else \"?{}\".format(urlencode(kwargs.get(\"qsd\")))\n        ),\n    )\n\n\ndef urlencode(query, doseq=False, safe=\"\", encoding=None, errors=None):\n    \"\"\"Convert a mapping object or a sequence of two-element tuples.\n\n    Wrapper to Python's unquote while remaining compatible with both\n    Python 2 & 3 since the reference to this function changed between\n    versions.\n\n    The resulting string is a series of key=value pairs separated by '&'\n    characters, where both key and value are quoted using the quote()\n    function.\n\n    Note: If the dictionary entry contains an entry that is set to None\n          it is not included in the final result set. If you want to\n          pass in an empty variable, set it to an empty string.\n\n    Args:\n        query (str): The dictionary to encode\n        doseq (:obj:`bool`, optional): Handle sequences\n        safe (:obj:`str`): non-ascii characters and URI specific ones that\n            you do not wish to escape (if detected). Setting this string\n            to an empty one causes everything to be escaped.\n        encoding (:obj:`str`, optional): encoding type\n        errors (:obj:`str`, errors): how to handle invalid character found\n            in encoded string (defined by encoding)\n\n    Returns:\n        str: The escaped parameters returned as a string\n    \"\"\"\n    # Tidy query by eliminating any records set to None\n    query_ = {k: v for (k, v) in query.items() if v is not None}\n    return _urlencode(\n        query_, doseq=doseq, safe=safe, encoding=encoding, errors=errors\n    )\n\n\ndef parse_urls(*args, store_unparseable=True, **kwargs):\n    \"\"\"Takes a string containing URLs separated by comma's and/or spaces and\n    returns a list.\"\"\"\n\n    result = []\n    for arg in args:\n        if isinstance(arg, str) and arg:\n            result_ = URL_DETECTION_RE.findall(arg)\n            if result_:\n                result += result_\n\n            elif not result_ and store_unparseable:\n                # we had content passed into us that was lost because it was\n                # so poorly formatted that it didn't even come close to\n                # meeting the regular expression we defined. We intentially\n                # keep it as part of our result set so that parsing done\n                # at a higher level can at least report this to the end user\n                # and hopefully give them some indication as to what they\n                # may have done wrong.\n                result += list(filter(bool, re.split(STRING_DELIMITERS, arg)))\n\n        elif isinstance(arg, (set, list, tuple)):\n            # Use recursion to handle the list of URLs\n            result += parse_urls(*arg, store_unparseable=store_unparseable)\n\n    return result\n\n\ndef parse_list(*args, cast=None, allow_whitespace=True, sort=True):\n    \"\"\"Take a string list and break it into a delimited list of arguments. This\n    funciton also supports the processing of a list of delmited strings and\n    will always return a unique set of arguments. Duplicates are always\n    combined in the final results.\n\n    You can append as many items to the argument listing for\n    parsing.\n\n    Hence: parse_list('.mkv, .iso, .avi') becomes:\n        ['.mkv', '.iso', '.avi']\n\n    Hence: parse_list('.mkv, .iso, .avi', ['.avi', '.mp4']) becomes:\n        ['.mkv', '.iso', '.avi', '.mp4']\n\n    The parsing is very forgiving and accepts spaces, slashes, commas\n    semicolons, and pipes as delimiters\n    \"\"\"\n\n    result = []\n    for arg in args:\n        if not isinstance(arg, (str, set, list, bool, tuple)) and arg and cast:\n            arg = cast(arg)\n\n        if isinstance(arg, str):\n            result += re.split(\n                (\n                    STRING_DELIMITERS\n                    if allow_whitespace\n                    else STRING_DELIMITERS_NO_WS\n                ),\n                arg,\n            )\n\n        elif isinstance(arg, (set, list, tuple)):\n            result += parse_list(\n                *arg, allow_whitespace=allow_whitespace, sort=sort)\n\n    #\n    # filter() eliminates any empty entries\n    #\n    # Since Python v3 returns a filter (iterator) whereas Python v2 returned\n    # a list, we need to change it into a list object to remain compatible with\n    # both distribution types.\n    if sort:\n        return (\n            sorted(filter(bool, list(set(result))))\n            if allow_whitespace\n            else sorted(\n                [x.strip() for x in filter(\n                    bool, list(set(result))) if x.strip()]\n            )\n        )\n    return (\n        list(filter(bool, list(result)))\n        if allow_whitespace\n        else [x.strip() for x in filter(bool, list(result)) if x.strip()]\n    )\n\n\ndef validate_regex(value, regex=r\"[^\\s]+\", flags=re.I, strip=True, fmt=None):\n    \"\"\"A lot of the tokens, secrets, api keys, etc all have some regular\n    expression validation they support.  This hashes the regex after it's\n    compiled and returns it's content if matched, otherwise it returns None.\n\n    This function greatly increases performance as it prevents apprise modules\n    from having to pre-compile all of their regular expressions.\n\n    value is the element being tested regex is the regular expression to be\n    compiled and tested. By default  we extract the first chunk of code while\n    eliminating surrounding  whitespace (if present)\n\n    flags is the regular expression flags that should be applied format is used\n    to alter the response format if the regular  expression matches. You\n    identify your format using {tags}.  Effectively nesting your ID's between\n    {}. Consider a regex of:   '(?P<year>[0-9]{2})[0-9]+(?P<value>[A-Z])' to\n    which you could set your format up as '{value}-{year}'. This would\n    substitute the matched groups and format a response.\n    \"\"\"\n\n    if flags:\n        # Regex String -> Flag Lookup Map\n        map_ = {\n            # Ignore Case\n            \"i\": re.I,\n            # Multi Line\n            \"m\": re.M,\n            # Dot Matches All\n            \"s\": re.S,\n            # Locale Dependant\n            \"L\": re.L,\n            # Unicode Matching\n            \"u\": re.U,\n            # Verbose\n            \"x\": re.X,\n        }\n\n        if isinstance(flags, str):\n            # Convert a string of regular expression flags into their\n            # respected integer (expected) Python values and perform\n            # a bit-wise or on each match found:\n            flags = reduce(\n                lambda x, y: x | y, [0] + [map_[f] for f in flags if f in map_]\n            )\n\n    else:\n        # Handles None/False/'' cases\n        flags = 0\n\n    # A key is used to store our compiled regular expression\n    key = f\"{regex}{flags}\"\n\n    if key not in REGEX_VALIDATE_LOOKUP:\n        REGEX_VALIDATE_LOOKUP[key] = re.compile(regex, flags)\n\n    # Perform our lookup usig our pre-compiled result\n    try:\n        result = REGEX_VALIDATE_LOOKUP[key].match(value)\n        if not result:\n            # let outer exception handle this\n            raise TypeError\n\n        if fmt:\n            # Map our format back to our response\n            value = fmt.format(**result.groupdict())\n\n    except (TypeError, AttributeError):\n        return None\n\n    # Return our response\n    return value.strip() if strip else value\n"
  },
  {
    "path": "apprise/utils/pem.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport base64\nimport binascii\nimport json\nimport os\nimport struct\nfrom typing import Optional, Union\n\nfrom ..apprise_attachment import AppriseAttachment\nfrom ..asset import AppriseAsset\nfrom ..exception import ApprisePluginException\nfrom ..logger import logger\nfrom ..utils.base64 import base64_urldecode, base64_urlencode\n\ntry:\n    from cryptography.exceptions import InvalidTag\n    from cryptography.hazmat.backends import default_backend\n    from cryptography.hazmat.primitives import hashes, serialization\n    from cryptography.hazmat.primitives.asymmetric import ec\n    from cryptography.hazmat.primitives.asymmetric.utils import (\n        decode_dss_signature,\n    )\n    from cryptography.hazmat.primitives.ciphers import (\n        Cipher,\n        algorithms,\n        modes,\n    )\n    from cryptography.hazmat.primitives.ciphers.aead import AESGCM\n    from cryptography.hazmat.primitives.kdf.hkdf import HKDF\n    from cryptography.hazmat.primitives.serialization import (\n        Encoding,\n        NoEncryption,\n        PrivateFormat,\n        PublicFormat,\n    )\n\n    # PEM Support enabled\n    PEM_SUPPORT = True\n\nexcept ImportError:\n    # PEM Support disabled\n    PEM_SUPPORT = False\n\n\nclass ApprisePEMException(ApprisePluginException):\n    \"\"\"Thrown when there is an error with the PEM Controller.\"\"\"\n\n    def __init__(self, message, error_code=612):\n        super().__init__(message, error_code=error_code)\n\n\nclass ApprisePEMController:\n    \"\"\"PEM Controller Tool for the Apprise Library.\"\"\"\n\n    # There is no reason a PEM Public Key should exceed 8K in size\n    # If it is more than this, then it is not accepted\n    max_pem_public_key_size = 8000\n\n    # There is no reason a PEM Private Key should exceed 8K in size\n    # If it is more than this, then it is not accepted\n    max_pem_private_key_size = 8000\n\n    # Maximum Vapid Message Size\n    max_webpush_record_size = 4096\n\n    def __init__(\n        self,\n        path: str,\n        pub_keyfile: Optional[str] = None,\n        prv_keyfile: Optional[str] = None,\n        name: Optional[str] = None,\n        asset: Optional[AppriseAsset] = None,\n        **kwargs,\n    ) -> None:\n        \"\"\"Path should be the directory keys can be written and read from such\n        as <notifyobject>.store.path.\n\n        Optionally additionally specify a keyfile to explicitly open\n        \"\"\"\n\n        # Directory we can work with\n        self.path = path\n\n        # Prepare our Key Placeholders\n        self.__private_key = None\n        self.__public_key = None\n\n        # Our name (id)\n        self.name = (\n            name.strip(\" \\t/-+!$@#*\").lower() if isinstance(name, str) else \"\"\n        )\n\n        # Prepare our Asset Object\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        # Our temporary reference points\n        self._prv_keyfile = AppriseAttachment(asset=self.asset)\n        self._pub_keyfile = AppriseAttachment(asset=self.asset)\n\n        if prv_keyfile:\n            self.load_private_key(prv_keyfile)\n\n        elif pub_keyfile:\n            self.load_public_key(pub_keyfile)\n\n        else:\n            self._pub_keyfile = None\n\n    def load_private_key(\n        self, path: Optional[str] = None, *names: str\n    ) -> bool:\n        \"\"\"Load Private key and from that we can prepare our public key.\"\"\"\n\n        if path is None:\n            # Auto-load our content\n            return bool(self.private_keyfile(*names))\n\n        # Create ourselves an Attachment to work with; this grants us the\n        # ability to pull this key from a remote site or anything else\n        # supported by the Attachment object\n        self._prv_keyfile = AppriseAttachment(asset=self.asset)\n\n        # Add our definition to our pem_key reference\n        self._prv_keyfile.add(path)\n\n        # Enforce maximum file size\n        self._prv_keyfile[0].max_file_size = self.max_pem_private_key_size\n\n        #\n        # Reset Public key\n        #\n        self._pub_keyfile = AppriseAttachment(asset=self.asset)\n\n        #\n        # Reset our internal keys\n        #\n        self.__private_key = None\n        self.__public_key = None\n\n        if not self._prv_keyfile.sync():\n            # Early exit\n            logger.error(f\"Could not access PEM Private Key {path}.\")\n            return False\n\n        try:\n            with open(self._prv_keyfile[0].path, \"rb\") as f:\n                self.__private_key = serialization.load_pem_private_key(\n                    f.read(),\n                    password=None,  # or provide the password if encrypted\n                    backend=default_backend(),\n                )\n\n        except (ValueError, TypeError):\n            logger.debug(\n                \"PEM Private Key file specified is not supported (%s)\",\n                type(path),\n            )\n            return False\n\n        except FileNotFoundError:\n            logger.debug(\"PEM Private Key file not found: %s\", path)\n            return False\n\n        except OSError as e:\n            logger.warning(\"Error accessing PEM Private Key file %s\", path)\n            logger.debug(f\"I/O Exception: {e}\")\n            return False\n\n        #\n        # Generate our public key\n        #\n        self.__public_key = self.__private_key.public_key()\n\n        # Load our private key\n        return bool(self.__private_key)\n\n    def load_public_key(self, path: Optional[str] = None, *names: str) -> bool:\n        \"\"\"Load Public key only.\n\n        Note: with just a public key you can only decrypt, encryption is not\n              possible.\n        \"\"\"\n\n        if path is None:\n            # Auto-load our content\n            return bool(self.public_keyfile(*names))\n\n        # Create ourselves an Attachment to work with; this grants us the\n        # ability to pull this key from a remote site or anything else\n        # supported by the Attachment object\n        self._pub_keyfile = AppriseAttachment(asset=self.asset)\n\n        # Add our definition to our pem_key reference\n        self._pub_keyfile.add(path)\n\n        # Enforce maximum file size\n        self._pub_keyfile[0].max_file_size = self.max_pem_public_key_size\n\n        #\n        # Reset Private key\n        #\n        self._prv_keyfile = AppriseAttachment(asset=self.asset)\n\n        #\n        # Reset our internal keys\n        #\n        self.__private_key = None\n        self.__public_key = None\n\n        if not self._pub_keyfile.sync():\n            # Early exit\n            logger.error(f\"Could not access PEM Public Key {path}.\")\n            return False\n\n        try:\n            with open(path, \"rb\") as key_file:\n                self.__public_key = serialization.load_pem_public_key(\n                    key_file.read(), backend=default_backend()\n                )\n\n        except (ValueError, TypeError):\n            logger.debug(\n                \"PEM Public Key file specified is not supported (%s)\",\n                type(path),\n            )\n            return False\n\n        except FileNotFoundError:\n            # Generate keys\n            logger.debug(\"PEM Public Key file not found: %s\", path)\n            return False\n\n        except OSError as e:\n            logger.warning(\"Error accessing PEM Public Key file %s\", path)\n            logger.debug(f\"I/O Exception: {e}\")\n            return False\n\n        # Load our private key\n        return bool(self.__public_key)\n\n    def keygen(self, name: \"Optional[str]\" = None, force: bool = False):\n        \"\"\"Generates a set of keys based on name configured.\"\"\"\n\n        if not PEM_SUPPORT:\n            msg = \"PEM Support unavailable; install cryptography library\"\n            logger.warning(msg)\n            raise ApprisePEMException(msg)\n\n        # Detect if a key has been loaded or not\n        has_key = bool(\n            self.private_key(autogen=False) or self.public_key(autogen=False)\n        )\n\n        if (has_key and not (name or force)) or not self.path:\n            logger.trace(\n                \"PEM keygen disabled, reason=%s\",\n                \"keyfile-defined\" if not has_key else \"no-write-path\",\n            )\n            return False\n\n        # Create a new private/public key pair\n        self.__private_key = ec.generate_private_key(\n            ec.SECP256R1(), default_backend()\n        )\n        self.__public_key = self.__private_key.public_key()\n\n        #\n        # Prepare our PEM formatted output files\n        #\n        private_key = self.__private_key.private_bytes(\n            Encoding.PEM,\n            PrivateFormat.PKCS8,\n            encryption_algorithm=NoEncryption(),\n        )\n\n        public_key = self.__public_key.public_bytes(\n            encoding=Encoding.PEM,\n            format=PublicFormat.SubjectPublicKeyInfo,\n        )\n\n        if not name:\n            name = self.name\n\n        file_prefix = \"\" if not name else f\"{name}-\"\n        pub_path = os.path.join(self.path, f\"{file_prefix}public_key.pem\")\n        prv_path = os.path.join(self.path, f\"{file_prefix}private_key.pem\")\n\n        if not force:\n            if os.path.isfile(pub_path):\n                logger.debug(\n                    \"PEM generation skipped; Public Key already exists: %s/%s\",\n                    os.path.dirname(pub_path),\n                    os.path.basename(pub_path),\n                )\n                return False\n\n            if os.path.isfile(prv_path):\n                logger.debug(\n                    \"PEM generation skipped; Private Key already exists: %s%s\",\n                    os.path.dirname(prv_path),\n                    os.path.basename(prv_path),\n                )\n                return False\n\n        try:\n            # Write our keys to disk\n            with open(pub_path, \"wb\") as f:\n                f.write(public_key)\n\n        except OSError as e:\n            logger.warning(\"Error writing Public PEM file %s\", pub_path)\n            logger.debug(f\"I/O Exception: {e}\")\n\n            # Cleanup\n            try:\n                os.unlink(pub_path)\n                logger.trace(\"Removed %s\", pub_path)\n\n            except OSError:\n                pass\n\n            return False\n\n        try:\n            with open(prv_path, \"wb\") as f:\n                f.write(private_key)\n\n        except OSError as e:\n            logger.warning(\"Error writing Private PEM file %s\", prv_path)\n            logger.debug(f\"I/O Exception: {e}\")\n\n            try:\n                os.unlink(pub_path)\n                logger.trace(\"Removed %s\", pub_path)\n\n            except OSError:\n                pass\n\n            try:\n                os.unlink(prv_path)\n                logger.trace(\"Removed %s\", prv_path)\n\n            except OSError:\n                pass\n\n            return False\n\n        # Update our local file references\n        self._prv_keyfile = AppriseAttachment(asset=self.asset)\n        self._prv_keyfile.add(prv_path)\n\n        self._pub_keyfile = AppriseAttachment(asset=self.asset)\n        self._pub_keyfile.add(pub_path)\n\n        logger.info(\n            \"Wrote Public/Private PEM key pair for %s/%s\",\n            os.path.dirname(pub_path),\n            os.path.basename(pub_path),\n        )\n        return True\n\n    def public_keyfile(self, *names: str) -> Optional[str]:\n        \"\"\"Returns the first match of a useable public key based names\n        provided.\"\"\"\n\n        if not PEM_SUPPORT:\n            msg = \"PEM Support unavailable; install cryptography library\"\n            logger.warning(msg)\n            raise ApprisePEMException(msg)\n\n        if self._pub_keyfile:\n            # If our code reaches here, then we fetch our public key\n            pem_key = self._pub_keyfile[0]\n            if not pem_key:\n                # We could not access the attachment\n                logger.error(\n                    \"Could not access PEM Public Key\"\n                    f\" {pem_key.url(privacy=True)}.\"\n                )\n                return False\n\n            return pem_key.path\n\n        elif not self.path:\n            # No path\n            return None\n\n        fnames = [\n            \"public_key.pem\",\n            \"public.pem\",\n            \"pub.pem\",\n        ]\n\n        if self.name:\n            # Include our name in the list\n            fnames = [self.name, *names]\n\n        for name in names:\n            fnames.insert(0, f\"{name}-public_key.pem\")\n\n            entry = name.lower()\n            fnames.insert(0, f\"{entry}-public_key.pem\")\n\n        return next(\n            (\n                os.path.join(self.path, fname)\n                for fname in fnames\n                if os.path.isfile(os.path.join(self.path, fname))\n            ),\n            None,\n        )\n\n    def private_keyfile(self, *names: str) -> Optional[str]:\n        \"\"\"Returns the first match of a useable private key based names\n        provided.\"\"\"\n\n        if not PEM_SUPPORT:\n            msg = \"PEM Support unavailable; install cryptography library\"\n            logger.warning(msg)\n            raise ApprisePEMException(msg)\n\n        if self._prv_keyfile:\n            # If our code reaches here, then we fetch our private key\n            pem_key = self._prv_keyfile[0]\n            if not pem_key:\n                # We could not access the attachment\n                logger.error(\n                    \"Could not access PEM Private Key\"\n                    f\" {pem_key.url(privacy=True)}.\"\n                )\n                return False\n\n            return pem_key.path\n\n        elif not self.path:\n            # No path\n            return None\n\n        fnames = [\n            \"private_key.pem\",\n            \"private.pem\",\n            \"prv.pem\",\n        ]\n\n        if self.name:\n            # Include our name in the list\n            fnames = [self.name, *names]\n\n        for name in names:\n            fnames.insert(0, f\"{name}-private_key.pem\")\n\n            entry = name.lower()\n            fnames.insert(0, f\"{entry}-private_key.pem\")\n\n        return next(\n            (\n                os.path.join(self.path, fname)\n                for fname in fnames\n                if os.path.isfile(os.path.join(self.path, fname))\n            ),\n            None,\n        )\n\n    def public_key(\n        self,\n        *names: str,\n        autogen: Optional[bool] = None,\n        autodetect: bool = True,\n    ) -> Optional[\"ec.EllipticCurvePublicKey\"]:\n        \"\"\"Opens a spcified pem public file and returns the key from it which\n        is used to decrypt the message.\"\"\"\n        if self.__public_key or not autodetect:\n            return self.__public_key\n\n        path = self.public_keyfile(*names)\n        if not path:\n            if (\n                autogen if autogen is not None else self.asset.pem_autogen\n            ) and self.keygen(*names):\n                path = self.public_keyfile(*names)\n                if path:\n                    # We should get a hit now\n                    return self.public_key(autogen=False)\n\n            logger.warning(\"No PEM Public Key could be loaded\")\n            return None\n\n        return (\n            self.__public_key\n            if (\n                self.load_public_key(path)\n                or\n                # Try to see if we can load a private key (which we ca\n                # generate a public from)\n                self.private_key(*names, autogen=autogen)\n            )\n            else None\n        )\n\n    def private_key(\n        self,\n        *names: str,\n        autogen: Optional[bool] = None,\n        autodetect: bool = True,\n    ) -> Optional[\"ec.EllipticCurvePrivateKey\"]:\n        \"\"\"Opens a spcified pem private file and returns the key from it which\n        is used to encrypt the message.\"\"\"\n        if self.__private_key or not autodetect:\n            return self.__private_key\n\n        path = self.private_keyfile(*names)\n        if not path:\n            if (\n                autogen if autogen is not None else self.asset.pem_autogen\n            ) and self.keygen(*names):\n                path = self.private_keyfile(*names)\n                if path:\n                    # We should get a hit now\n                    return self.private_key(autogen=False)\n\n            logger.warning(\"No PEM Private Key could be loaded\")\n            return None\n\n        return self.__private_key if self.load_private_key(path) else None\n\n    def encrypt_webpush(\n        self,\n        message: Union[str, bytes],\n        public_key: \"ec.EllipticCurvePublicKey\",\n        auth_secret: bytes,\n    ) -> bytes:\n        \"\"\"Encrypt a WebPush message using the recipient's public key and auth\n        secret.\n\n        Accepts input message as str or bytes.\n        \"\"\"\n        if isinstance(message, str):\n            message = message.encode(\"utf-8\")\n\n        # 1. Generate ephemeral EC private/Public key\n        ephemeral_private_key = ec.generate_private_key(\n            ec.SECP256R1(), default_backend()\n        )\n        ephemeral_public_key = ephemeral_private_key.public_key().public_bytes(\n            encoding=Encoding.X962, format=PublicFormat.UncompressedPoint\n        )\n\n        # 2. Random salt\n        salt = os.urandom(16)\n\n        # 3. Generate shared secret via ECDH\n        shared_secret = ephemeral_private_key.exchange(ec.ECDH(), public_key)\n\n        # 4. Derive PRK using HKDF (first phase)\n        recipient_public_key_bytes = public_key.public_bytes(\n            encoding=Encoding.X962,\n            format=PublicFormat.UncompressedPoint,\n        )\n\n        # 5. Derive Encryption key\n        hkdf_secret = HKDF(\n            algorithm=hashes.SHA256(),\n            length=32,\n            salt=auth_secret,\n            info=b\"WebPush: info\\x00\"\n            + recipient_public_key_bytes\n            + ephemeral_public_key,\n            backend=default_backend(),\n        ).derive(shared_secret)\n\n        # 6. Derive Content Encryption Key\n        hkdf_key = HKDF(\n            algorithm=hashes.SHA256(),\n            length=16,\n            salt=salt,\n            info=b\"Content-Encoding: aes128gcm\\x00\",\n            backend=default_backend(),\n        ).derive(hkdf_secret)\n\n        # 7. Derive Nonce\n        hkdf_nonce = HKDF(\n            algorithm=hashes.SHA256(),\n            length=12,\n            salt=salt,\n            info=b\"Content-Encoding: nonce\\x00\",\n            backend=default_backend(),\n        ).derive(hkdf_secret)\n\n        # 8. Encrypt the message\n        aesgcm = AESGCM(hkdf_key)\n        # RFC8291 requires us to add '\\0x02' byte to end of message\n        ciphertext = aesgcm.encrypt(\n            hkdf_nonce, message + b\"\\x02\", associated_data=None\n        )\n\n        # 9. Build WebPush header + payload\n        header = salt\n        header += struct.pack(\"!L\", self.max_webpush_record_size)\n        header += struct.pack(\"!B\", len(ephemeral_public_key))\n        header += ephemeral_public_key\n        header += ciphertext\n\n        return header\n\n    def encrypt(\n        self,\n        message: Union[str, bytes],\n        public_key: \"Optional[ec.EllipticCurvePublicKey]\" = None,\n        salt: Optional[bytes] = None,\n    ) -> Optional[str]:\n        \"\"\"Encrypts a message using the recipient's public key (or self public\n        key if none provided).\n\n        Message can be str or bytes.\n        \"\"\"\n\n        if not PEM_SUPPORT:\n            msg = \"PEM Support unavailable; install cryptography library\"\n            logger.warning(msg)\n            raise ApprisePEMException(msg)\n\n        # 1. Handle string vs bytes input\n        if isinstance(message, str):\n            message = message.encode(\"utf-8\")\n\n        # 2. Select public key\n        if public_key is None:\n            public_key = self.public_key()\n            if public_key is None:\n                logger.debug(\"No public key available for encryption.\")\n                return None\n\n        # 3. Generate ephemeral EC private key\n        ephemeral_private_key = ec.generate_private_key(\n            ec.SECP256R1(), default_backend()\n        )\n\n        # 4. Derive shared secret\n        shared_secret = ephemeral_private_key.exchange(ec.ECDH(), public_key)\n\n        # 5. Derive symmetric AES key using HKDF\n        derived_key = HKDF(\n            algorithm=hashes.SHA256(),\n            length=32,\n            salt=salt,  # Allow salt=None if not provided\n            info=b\"ecies-encryption\",\n            backend=default_backend(),\n        ).derive(shared_secret)\n\n        # 6. Encrypt the message using AES-GCM\n        iv = os.urandom(12)  # 96-bit random IV for GCM\n        encryptor = Cipher(\n            algorithms.AES(derived_key),\n            modes.GCM(iv),\n            backend=default_backend(),\n        ).encryptor()\n\n        ciphertext = encryptor.update(message) + encryptor.finalize()\n        tag = encryptor.tag\n\n        # 7. Serialize ephemeral public key as X9.62 Uncompressed Point\n        ephemeral_public_key_bytes = (\n            ephemeral_private_key.public_key().public_bytes(\n                encoding=serialization.Encoding.X962,\n                format=serialization.PublicFormat.UncompressedPoint,\n            )\n        )\n\n        # 8. Combine everything cleanly\n        full_payload = {\n            \"ephemeral_pubkey\": base64_urlencode(ephemeral_public_key_bytes),\n            \"iv\": base64_urlencode(iv),\n            \"tag\": base64_urlencode(tag),\n            \"ciphertext\": base64_urlencode(ciphertext),\n        }\n\n        return base64.b64encode(\n            json.dumps(full_payload).encode(\"utf-8\")\n        ).decode(\"utf-8\")\n\n    def decrypt(\n        self,\n        encrypted_payload: Union[str, bytes],\n        private_key: \"Optional[ec.EllipticCurvePrivateKey]\" = None,\n        salt: Optional[bytes] = None,\n    ) -> Optional[str]:\n        \"\"\"Decrypts a message using the provided private key or fallback to\n        self's private key.\n\n        Payload is the base64-encoded JSON from encrypt().\n        \"\"\"\n\n        if not PEM_SUPPORT:\n            msg = \"PEM Support unavailable; install cryptography library\"\n            logger.warning(msg)\n            raise ApprisePEMException(msg)\n\n        # 1. Parse input\n        try:\n            if isinstance(encrypted_payload, str):\n                payload_bytes = base64.b64decode(\n                    encrypted_payload.encode(\"utf-8\")\n                )\n\n            else:\n                payload_bytes = base64.b64decode(encrypted_payload)\n\n        except binascii.Error:\n            # Bad Padding\n            logger.debug(\"Unparseable encrypted content provided\")\n            return None\n\n        try:\n            payload = json.loads(payload_bytes.decode(\"utf-8\"))\n\n        except UnicodeDecodeError:\n            logger.debug(\"Unparseable encrypted content provided\")\n            return None\n\n        ephemeral_pubkey_bytes = base64_urldecode(payload[\"ephemeral_pubkey\"])\n        iv = base64_urldecode(payload[\"iv\"])\n        tag = base64_urldecode(payload[\"tag\"])\n        ciphertext = base64_urldecode(payload[\"ciphertext\"])\n\n        # 2. Select private key\n        if private_key is None:\n            private_key = self.private_key()\n            if private_key is None:\n                logger.debug(\"No private key available for decryption.\")\n                return None\n\n        # 3. Load ephemeral public key from sender\n        ephemeral_pubkey = ec.EllipticCurvePublicKey.from_encoded_point(\n            ec.SECP256R1(), ephemeral_pubkey_bytes\n        )\n\n        # 4. ECDH shared secret\n        shared_secret = private_key.exchange(ec.ECDH(), ephemeral_pubkey)\n\n        # 5. Derive symmetric AES key with HKDF\n        derived_key = HKDF(\n            algorithm=hashes.SHA256(),\n            length=32,\n            salt=salt,\n            info=b\"ecies-encryption\",\n            backend=default_backend(),\n        ).derive(shared_secret)\n\n        # 6. Decrypt using AES-GCM\n        decryptor = Cipher(\n            algorithms.AES(derived_key),\n            modes.GCM(iv, tag),\n            backend=default_backend(),\n        ).decryptor()\n\n        try:\n            plaintext = decryptor.update(ciphertext) + decryptor.finalize()\n\n        except InvalidTag:\n            logger.debug(\"Decryption failed - Authentication Mismatch\")\n            # Reason for Error:\n            #   - Mismatched or missing salt\n            #   - Mismatched iv, tag, or ciphertext\n            #   - Incorrect or corrupted ephemeral_pubkey\n            #   - Wrong or incomplete key derivation\n            #   - Data being altered between encryption and decryption\n            #     (truncated/corrupted)\n\n            # Basically if we get here, we tried to decrypt encrypted content\n            # using the wrong key.\n            return None\n\n        # 7. Return decoded message\n        return plaintext.decode(\"utf-8\")\n\n    def sign(self, content: bytes) -> Optional[bytes]:\n        \"\"\"Sign the message using ES256 (ECDSA w/ SHA256) via private key.\"\"\"\n\n        try:\n            # Sign the message using ES256 (ECDSA w/ SHA256)\n            der_sig = self.private_key().sign(\n                content, ec.ECDSA(hashes.SHA256())\n            )\n\n        except AttributeError:\n            # NoneType; could not load key\n            return None\n\n        # Convert DER to raw R||S\n        r, s = decode_dss_signature(der_sig)\n        return r.to_bytes(32, byteorder=\"big\") + s.to_bytes(\n            32, byteorder=\"big\"\n        )\n\n    @property\n    def pub_keyfile(self) -> Optional[Union[str, bool]]:\n        \"\"\"Returns the Public Keyfile Path if set otherwise it returns None\n        This property returns False if a keyfile was provided, but was\n        invalid.\"\"\"\n        return (\n            None\n            if not self._pub_keyfile\n            else (\n                False\n                if not self._pub_keyfile[0]\n                else self._pub_keyfile[0].path\n            )\n        )\n\n    @property\n    def prv_keyfile(self) -> Optional[Union[str, bool]]:\n        \"\"\"Returns the Private Keyfile Path if set otherwise it returns None\n        This property returns False if a keyfile was provided, but was\n        invalid.\"\"\"\n        return (\n            None\n            if not self._prv_keyfile\n            else (\n                False\n                if not self._prv_keyfile[0]\n                else self._prv_keyfile[0].path\n            )\n        )\n\n    @property\n    def x962_str(self) -> str:\n        \"\"\"X962 serialization based on public key.\"\"\"\n        try:\n            return base64_urlencode(\n                self.public_key().public_bytes(\n                    encoding=serialization.Encoding.X962,\n                    format=serialization.PublicFormat.UncompressedPoint,\n                )\n            )\n        except AttributeError:\n            # Public Key could not be generated (public_key() returned None)\n            return \"\"\n\n    def __bool__(self) -> bool:\n        \"\"\"Returns True if at least 1 key was loaded.\"\"\"\n        return bool(self.private_key() or self.public_key())\n"
  },
  {
    "path": "apprise/utils/pgp.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timedelta, timezone\nimport hashlib\nimport os\n\nfrom ..apprise_attachment import AppriseAttachment\nfrom ..asset import AppriseAsset\nfrom ..exception import ApprisePluginException\nfrom ..logger import logger\n\ntry:\n    import pgpy\n\n    # Pretty Good Privacy (PGP) Support enabled\n    PGP_SUPPORT = True\n\nexcept ImportError:\n    # Pretty Good Privacy (PGP) Support disabled\n    PGP_SUPPORT = False\n\n\nclass ApprisePGPException(ApprisePluginException):\n    \"\"\"Thrown when there is an error with the Pretty Good Privacy\n    Controller.\"\"\"\n\n    def __init__(self, message, error_code=602):\n        super().__init__(message, error_code=error_code)\n\n\nclass ApprisePGPController:\n    \"\"\"Pretty Good Privacy Controller Tool for the Apprise Library.\"\"\"\n\n    # There is no reason a PGP Public Key should exceed 8K in size\n    # If it is more than this, then it is not accepted\n    max_pgp_public_key_size = 8000\n\n    def __init__(\n        self, path, pub_keyfile=None, email=None, asset=None, **kwargs\n    ):\n        \"\"\"Path should be the directory keys can be written and read from such\n        as <notifyobject>.store.path.\n\n        Optionally additionally specify a keyfile to explicitly open\n        \"\"\"\n\n        # PGP hash\n        self.__key_lookup = {}\n\n        # Directory we can work with\n        self.path = path\n\n        # Our email\n        self.email = email\n\n        # Prepare our Asset Object\n        self.asset = (\n            asset if isinstance(asset, AppriseAsset) else AppriseAsset()\n        )\n\n        if pub_keyfile:\n            # Create ourselves an Attachment to work with; this grants us the\n            # ability to pull this key from a remote site or anything else\n            # supported by the Attachment object\n            self._pub_keyfile = AppriseAttachment(asset=self.asset)\n\n            # Add our definition to our pgp_key reference\n            self._pub_keyfile.add(pub_keyfile)\n\n            # Enforce maximum file size\n            self._pub_keyfile[0].max_file_size = self.max_pgp_public_key_size\n\n        else:\n            self._pub_keyfile = None\n\n    def keygen(self, email=None, name=None, force=False):\n        \"\"\"Generates a set of keys based on email configured.\"\"\"\n\n        try:\n            # Create a new RSA key pair with 2048-bit strength\n            key = pgpy.PGPKey.new(\n                pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048\n            )\n\n        except NameError:\n            # PGPy not installed\n            logger.debug(\"PGPy not installed; keygen disabled\")\n            return False\n\n        if self._pub_keyfile is not None or not self.path:\n            logger.trace(\n                \"PGP keygen disabled, reason=%s\",\n                (\n                    \"keyfile-defined\"\n                    if self._pub_keyfile is not None\n                    else \"no-write-path\"\n                ),\n            )\n            return False\n\n        if not name:\n            name = self.asset.app_id\n\n        if not email:\n            email = self.email\n\n        # Prepare our UID\n        uid = pgpy.PGPUID.new(name, email=email)\n\n        # Filenames\n        file_prefix = email.split(\"@\")[0].lower()\n\n        pub_path = os.path.join(self.path, f\"{file_prefix}-pub.asc\")\n        prv_path = os.path.join(self.path, f\"{file_prefix}-prv.asc\")\n\n        if os.path.isfile(pub_path) and not force:\n            logger.debug(\n                \"PGP generation skipped; Public Key already exists: %s\",\n                pub_path,\n            )\n            return True\n\n        # Persistent Storage Key\n        lookup_key = hashlib.sha1(\n            os.path.abspath(pub_path).encode(\"utf-8\")\n        ).hexdigest()\n        if lookup_key in self.__key_lookup:\n            # Ensure our key no longer exists\n            del self.__key_lookup[lookup_key]\n\n        # Add the user ID to the key\n        key.add_uid(\n            uid,\n            usage={\n                pgpy.constants.KeyFlags.Sign,\n                pgpy.constants.KeyFlags.EncryptCommunications,\n            },\n            hashes=[pgpy.constants.HashAlgorithm.SHA256],\n            ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256],\n            compression=[pgpy.constants.CompressionAlgorithm.ZLIB],\n        )\n\n        try:\n            # Write our keys to disk\n            with open(pub_path, \"w\") as f:\n                f.write(str(key.pubkey))\n\n        except OSError as e:\n            logger.warning(\"Error writing PGP file %s\", pub_path)\n            logger.debug(f\"I/O Exception: {e}\")\n\n            # Cleanup\n            try:\n                os.unlink(pub_path)\n                logger.trace(\"Removed %s\", pub_path)\n\n            except OSError:\n                pass\n\n        try:\n            with open(prv_path, \"w\") as f:\n                f.write(str(key))\n\n        except OSError as e:\n            logger.warning(\"Error writing PGP file %s\", prv_path)\n            logger.debug(f\"I/O Exception: {e}\")\n\n            try:\n                os.unlink(pub_path)\n                logger.trace(\"Removed %s\", pub_path)\n\n            except OSError:\n                pass\n\n            try:\n                os.unlink(prv_path)\n                logger.trace(\"Removed %s\", prv_path)\n\n            except OSError:\n                pass\n\n            return False\n\n        logger.info(\n            \"Wrote PGP Keys for %s/%s\",\n            os.path.dirname(pub_path),\n            os.path.basename(pub_path),\n        )\n        return True\n\n    def public_keyfile(self, *emails):\n        \"\"\"Returns the first match of a useable public key based emails\n        provided.\"\"\"\n\n        if not PGP_SUPPORT:\n            msg = \"PGP Support unavailable; install PGPy library\"\n            logger.warning(msg)\n            raise ApprisePGPException(msg)\n\n        if self._pub_keyfile is not None:\n            # If our code reaches here, then we fetch our public key\n            pgp_key = self._pub_keyfile[0]\n            if not pgp_key:\n                # We could not access the attachment\n                logger.error(\n                    \"Could not access PGP Public Key\"\n                    f\" {pgp_key.url(privacy=True)}.\"\n                )\n                return False\n\n            return pgp_key.path\n\n        elif not self.path:\n            # No path\n            return None\n\n        fnames = [\n            \"pgp-public.asc\",\n            \"pgp-pub.asc\",\n            \"public.asc\",\n            \"pub.asc\",\n        ]\n\n        if self.email:\n            # Include our email in the list\n            emails = [self.email, *emails]\n\n        for email in emails:\n            entry = email.split(\"@\")[0].lower()\n            fnames.insert(0, f\"{entry}-pub.asc\")\n\n            # Lowercase email (Highest Priority)\n            entry = email.lower()\n            fnames.insert(0, f\"{entry}-pub.asc\")\n\n        return next(\n            (\n                os.path.join(self.path, fname)\n                for fname in fnames\n                if os.path.isfile(os.path.join(self.path, fname))\n            ),\n            None,\n        )\n\n    def public_key(self, *emails, autogen=None):\n        \"\"\"Opens a spcified pgp public file and returns the key from it which\n        is used to encrypt the message.\"\"\"\n        path = self.public_keyfile(*emails)\n        if not path:\n            if (\n                autogen if autogen is not None else self.asset.pgp_autogen\n            ) and self.keygen(*emails):\n                path = self.public_keyfile(*emails)\n                if path:\n                    # We should get a hit now\n                    return self.public_key(*emails)\n\n            logger.warning(\"No PGP Public Key could be loaded\")\n            return None\n\n        # Persistent Storage Key\n        key = hashlib.sha1(os.path.abspath(path).encode(\"utf-8\")).hexdigest()\n        if key in self.__key_lookup:\n            # Take an early exit\n            return self.__key_lookup[key][\"public_key\"]\n\n        try:\n            with open(path) as key_file:\n                public_key, _ = pgpy.PGPKey.from_blob(key_file.read())\n\n        except NameError:\n            # PGPy not installed\n            logger.debug(\"PGPy not installed; skipping PGP support: %s\", path)\n            return None\n\n        except FileNotFoundError:\n            # Generate keys\n            logger.debug(\"PGP Public Key file not found: %s\", path)\n            return None\n\n        except OSError as e:\n            logger.warning(\"Error accessing PGP Public Key file %s\", path)\n            logger.debug(f\"I/O Exception: {e}\")\n            return None\n\n        self.__key_lookup[key] = {\n            \"public_key\": public_key,\n            \"expires\": datetime.now(timezone.utc) + timedelta(seconds=86400),\n        }\n        return public_key\n\n    # Encrypt message using the recipient's public key\n    def encrypt(self, message, *emails):\n        \"\"\"If provided a path to a pgp-key, content is encrypted.\"\"\"\n\n        # Acquire our key\n        public_key = self.public_key(*emails)\n        if not public_key:\n            # Encryption not possible\n            return False\n\n        try:\n            message_object = pgpy.PGPMessage.new(message)\n            encrypted_message = public_key.encrypt(message_object)\n            return str(encrypted_message)\n\n        except pgpy.errors.PGPError:\n            # Encryption not Possible\n            logger.debug(\"PGP Public Key Corruption; encryption not possible\")\n\n        except NameError:\n            # PGPy not installed\n            logger.debug(\"PGPy not installed; Skipping PGP encryption\")\n\n        return None\n\n    def prune(self):\n        \"\"\"Prunes old entries from the public_key index.\"\"\"\n        self.__key_lookup = {\n            key: value\n            for key, value in self.__key_lookup.items()\n            if value[\"expires\"] > datetime.now(timezone.utc)\n        }\n\n    @property\n    def pub_keyfile(self):\n        \"\"\"Returns the Public Keyfile Path if set otherwise it returns None\n        This property returns False if a keyfile was provided, but was\n        invalid.\"\"\"\n        return (\n            None\n            if self._pub_keyfile is None\n            else (\n                False\n                if not self._pub_keyfile[0]\n                else self._pub_keyfile[0].path\n            )\n        )\n"
  },
  {
    "path": "apprise/utils/sanitize.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"Utilities that make payloads safe to *print* in debug and trace logs.\n\nThis module is intentionally scoped to log presentation only.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom hashlib import sha256\nfrom typing import Any, Optional\n\n# Keys that commonly contain large binary-like values.\n#\n# This list is used as a *hint* to enable a more aggressive summarization mode\n# for values underneath matching keys. The structure is still walked, only the\n# leaf string/bytes values are summarized more aggressively.\n#\n# Keep this list conservative. Very generic tokens may cause false positives.\n_BLOB_KEYWORDS = (\n    \"base64\",\n    \"attachment\",\n    \"attachments\",\n    \"base64_attachments\",\n    \"contentbytes\",\n    \"blob\",\n    \"file\",\n    \"data\",\n    \"image\",\n    \"media\",\n    \"document\",\n)\n\n\n@dataclass(frozen=True)\nclass SanitizeOptions:\n    \"\"\"Options controlling payload sanitization for debug logging.\n\n    The defaults are deliberately conservative and tuned for logging, not for\n    data processing. When in doubt, prefer smaller values to keep logging\n    cheap.\n\n    Attributes:\n        max_depth: Maximum recursion depth before truncation markers appear.\n        max_items: Global upper bound on visited items, across the whole walk.\n        max_str_len: Strings longer than this are summarized with a preview.\n        preview: Number of characters to show at the start and end of\n                 summaries.\n        hash_sample_size: Maximum bytes hashed when generating a sha256\n                          preview.\n        aggressive_blob_keys: If True, summarize values under blob-like keys\n            even when they are not huge, because these values are often encoded\n            attachments (for example, base64).\n    \"\"\"\n\n    # How many recursive lists/sets/tuples/dicts to delve into before\n    # aborting\n    max_depth: int = 10\n\n    # The max amount of fields to process before we just abort (too many)\n    max_items: int = 100\n\n    # Strings longer than this are summarized\n    max_str_len: int = 512\n\n    # Preview size for summarized strings\n    preview: int = 32\n\n    # Bound hashing work\n    # Strings longer than this (usually large attachments) include\n    # sha256 hash value of it in response\n    hash_sample_size: int = 8192\n\n    # If True, summarize values under blob-like keys even if they are smaller\n    # than the defined max_str_len.\n    aggressive_blob_keys: bool = True\n\n\ndef sanitize_payload(\n        value: Any, *, options: Optional[SanitizeOptions] = None) -> Any:\n    \"\"\"\n    This function is intended for DEBUG and TRACE logging only.\n\n    can add i/o to generate the printed copy, but the output is much\n    better then just printing what could be a massive payload (with\n    attachments).\n\n    The ideal setup for this function is when you need to print what\n    could be a very large object such as in the send() of a Apprise\n    service, you would structure it like this:\n\n        # check for at least the DEBUG level... you can also set\n        # logging.TRACE if you wanted as well:\n        if self.logger.isEnabledFor(logging.DEBUG):\n\n            # Then safely wrap the output using this function:\n            self.logger.debug(\n                \"Service Payload: %s\", sanitize_payload(payload))\n    \"\"\"\n    opts = options or SanitizeOptions()\n\n    # Track already-seen objects to prevent infinite loops on recursive graphs.\n    seen_ids: set[int] = set()\n\n    # Global counter that enforces opts.max_items across the entire traversal.\n    items_seen = 0\n\n    def _hash_bytes(b: bytes) -> str:\n        \"\"\"Return a short sha256 prefix for bytes, bounded by\n        hash_sample_size.\"\"\"\n        if len(b) > opts.hash_sample_size:\n            b = b[: opts.hash_sample_size]\n        return sha256(b).hexdigest()[:12]\n\n    def _summarize_str(s: str) -> str:\n        \"\"\"Summarize strings longer than opts.max_str_len with a preview.\"\"\"\n        nonlocal items_seen\n        items_seen += 1\n\n        length = len(s)\n        if length <= opts.max_str_len:\n            return s\n\n        head = s[: opts.preview]\n        tail = s[-opts.preview :] if length >= opts.preview else s\n        return f\"<string len={length} head={head!r} tail={tail!r}>\"\n\n    def _summarize_bytes(b: bytes) -> str:\n        \"\"\"Summarize bytes with length and a short bounded sha256 prefix.\"\"\"\n        nonlocal items_seen\n        items_seen += 1\n        return f\"<bytes len={len(b)} sha256={_hash_bytes(b)}>\"\n\n    def _is_blob_key(k: str) -> bool:\n        \"\"\"Return True if a key name indicates blob-like content.\n\n        This keeps behaviour predictable while avoiding heavy scanning of\n        values. Add items to _BLOB_KEYWORDS (not case sensitive) if required\n        to optimize speed of parsing.\n        \"\"\"\n        lk = k.lower()\n        return lk in _BLOB_KEYWORDS\n\n    def _summarize_key(k: Any) -> Any:\n        \"\"\"Summarize keys where needed, preserving readability in logs.\"\"\"\n        if isinstance(k, str):\n            # Keys are usually short. Only summarize if they are unexpectedly\n            # large to avoid log pollution.\n            return _summarize_str(k)\n        if isinstance(k, bytes):\n            return _summarize_bytes(k)\n        return k\n\n    def _walk(obj: Any, depth: int, *, blob_mode: bool = False) -> Any:\n        \"\"\"Recursively walk payload structures and summarize leaf values.\"\"\"\n        nonlocal items_seen\n\n        # Global safety limits first, so we can exit cheaply.\n        if items_seen >= opts.max_items:\n            return \"<truncated: global item limit reached>\"\n        if depth > opts.max_depth:\n            return \"<truncated: max depth reached>\"\n\n        # Pass-through primitives.\n        if obj is None or isinstance(obj, (bool, int, float)):\n            items_seen += 1\n            return obj\n\n        # Strings: optionally apply blob-mode summaries.\n        if isinstance(obj, str):\n            if blob_mode and opts.aggressive_blob_keys:\n                # Always summarize blob fields, even if not huge\n                length = len(obj)\n                head = obj[: opts.preview]\n                tail = obj[-opts.preview :] if length >= opts.preview else obj\n                items_seen += 1\n                return (\n                    f\"<string len={length} blob \"\n                    f\"head={head!r} tail={tail!r}>\")\n            return _summarize_str(obj)\n\n        # Bytes: always summarize.\n        if isinstance(obj, bytes):\n            return _summarize_bytes(obj)\n\n        # Prevent recursion loops on self-referential objects.\n        obj_id = id(obj)\n        if obj_id in seen_ids:\n            return \"<recursive>\"\n        seen_ids.add(obj_id)\n\n        # Dict: walk values. Keys may be summarized for readability.\n        if isinstance(obj, dict):\n            out: dict[Any, Any] = {}\n            for k, v in obj.items():\n                if items_seen >= opts.max_items:\n                    out[\"<truncated>\"] = \"...\"\n                    break\n\n                sk = _summarize_key(k)\n\n                # Enable blob mode for suspicious keys. We still walk the\n                # structure, but leaf values are summarized aggressively.\n                child_blob_mode = blob_mode\n                if isinstance(k, str) and _is_blob_key(k):\n                    child_blob_mode = True\n\n                out[sk] = _walk(v, depth + 1, blob_mode=child_blob_mode)\n            return out\n\n        # Sequences: walk each entry. Sets are returned as lists for\n        #            readability.\n        if isinstance(obj, (list, tuple, set, frozenset)):\n            out_list: list[Any] = []\n            for entry in obj:\n                if items_seen >= opts.max_items:\n                    out_list.append(\"<truncated: limit reached>\")\n                    break\n                out_list.append(_walk(entry, depth + 1, blob_mode=blob_mode))\n\n            if isinstance(obj, tuple):\n                return tuple(out_list)\n            return out_list\n\n        # Unknown objects: fall back to repr().\n        items_seen += 1\n        return repr(obj)\n\n    # Recursively walk all elements of the object passed\n    return _walk(value, 0)\n"
  },
  {
    "path": "apprise/utils/singleton.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\nclass Singleton(type):\n    \"\"\"Our Singleton MetaClass.\"\"\"\n\n    _instances = {}\n\n    def __call__(cls, *args, **kwargs):\n        \"\"\"Instantiate our singleton meta entry.\"\"\"\n        if cls not in cls._instances:\n            # we have not every built an instance before.  Build one now.\n            cls._instances[cls] = super().__call__(*args, **kwargs)\n        return cls._instances[cls]\n"
  },
  {
    "path": "apprise/utils/socket.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom __future__ import annotations\n\nimport contextlib\nimport ipaddress\nimport select\nimport socket\nimport ssl\nimport time\nfrom typing import Optional, Union\n\nfrom ..exception import AppriseException, AppriseInvalidData\nfrom ..logger import logger\n\nTimeoutType = Optional[\n    Union[float, tuple[Optional[float], Optional[float]]]\n]\n\n\nclass AppriseSocketError(AppriseException):\n    \"\"\"Raised for socket or TLS related failures.\"\"\"\n\n\nclass SocketTransport:\n    \"\"\"\n    TCP client transport with optional TLS upgrade.\n\n    Behaviour:\n      - secure=False (default): plain TCP\n      - secure=True: upgrade to TLS (immediately in connect(), or manually via\n        start_tls())\n      - verify=True (default): validate certificate chain and hostname using a\n        certifi CA bundle\n      - verify=False: accept invalid or self-signed certs\n\n    Timeout behaviour (requests-compatible):\n      - timeout=float => (connect, read) both set to float\n      - timeout=(connect, read) => tuple form\n      - None => no defaults (connect/read can block indefinitely)\n    \"\"\"\n\n    def __init__(\n        self,\n        host: str,\n        port: int,\n        bind_addr: Optional[str] = None,\n        bind_port: Optional[int] = None,\n        secure: bool = False,\n        verify: bool = True,\n        timeout: TimeoutType = 10.0,\n        retries: int = 0,\n    ) -> None:\n        self.host = host\n        self.port = int(port)\n        self.bind_addr = bind_addr\n        self.bind_port = bind_port\n\n        self.secure = bool(secure)\n        self.verify = bool(verify)\n        self.retries = retries\n\n        self._connect_timeout, self._read_timeout = \\\n            self._coerce_timeout(timeout)\n\n        self._sock: Optional[socket.socket] = None\n        self._rfile = None\n        self._wfile = None\n        self._is_tls: bool = False\n\n        # True once we have successfully read or written data since the last\n        # connect(). Used to decide whether reconnect attempts are allowed.\n        self._had_io: bool = False\n\n        self.local_addr: Optional[tuple[str, int]] = None\n        self.remote_addr: Optional[tuple[str, int]] = None\n\n    @staticmethod\n    def _coerce_timeout(\n            timeout: TimeoutType) -> tuple[Optional[float], Optional[float]]:\n        \"\"\"\n        Coerce requests-style timeout into (connect_timeout, read_timeout).\n        \"\"\"\n        if timeout is None:\n            return None, None\n\n        if isinstance(timeout, (int, float)):\n            t = float(timeout)\n            if t < 0:\n                raise AppriseInvalidData(\"timeout must be >= 0\")\n            return t, t\n\n        if isinstance(timeout, tuple) and len(timeout) == 2:\n            connect_t, read_t = timeout\n            if connect_t is not None:\n                connect_t = float(connect_t)\n                if connect_t < 0:\n                    raise AppriseInvalidData(\"connect timeout must be >= 0\")\n            if read_t is not None:\n                read_t = float(read_t)\n                if read_t < 0:\n                    raise AppriseInvalidData(\"read timeout must be >= 0\")\n            return connect_t, read_t\n\n        raise AppriseInvalidData(\n            \"timeout must be None, a float, or a (connect, read) tuple\"\n        )\n\n    @property\n    def connected(self) -> bool:\n        return self._sock is not None\n\n    @property\n    def is_tls(self) -> bool:\n        return self._is_tls\n\n    def close(self) -> None:\n        \"\"\"Close the socket and associated file wrappers.\"\"\"\n        try:\n            if self._wfile is not None:\n                with contextlib.suppress(Exception):\n                    self._wfile.flush()\n                with contextlib.suppress(Exception):\n                    self._wfile.close()\n        finally:\n            self._wfile = None\n\n        try:\n            if self._rfile is not None:\n                with contextlib.suppress(Exception):\n                    self._rfile.close()\n        finally:\n            self._rfile = None\n\n        if self._sock is not None:\n            try:\n                with contextlib.suppress(Exception):\n                    self._sock.shutdown(socket.SHUT_RDWR)\n\n                self._sock.close()\n            finally:\n                self._sock = None\n\n        self._is_tls = False\n        self._had_io = False\n        self.local_addr = None\n        self.remote_addr = None\n\n    def _refresh_wrappers(self) -> None:\n        \"\"\"Rebuild file wrappers, required after TLS upgrade.\"\"\"\n\n        if self._sock is None:\n            self._rfile = None\n            self._wfile = None\n            return\n\n        self._rfile = self._sock.makefile(\"rb\", buffering=0)\n        self._wfile = self._sock.makefile(\"wb\", buffering=0)\n\n    def can_read(self, timeout: float = 0.0) -> Optional[bool]:\n        \"\"\"Return True if readable, False if not, None if closed or error.\"\"\"\n        if self._sock is None:\n            return None\n        try:\n            r, _, x = select.select(\n                [self._sock], [], [self._sock], float(timeout))\n\n        except OSError:\n            self.close()\n            return None\n\n        if x:\n            self.close()\n            return None\n\n        return bool(r)\n\n    def can_write(self, timeout: float = 0.0) -> Optional[bool]:\n        \"\"\"Return True if writable, False if not, None if closed or error.\"\"\"\n        if self._sock is None:\n            return None\n        try:\n            _, w, x = \\\n                select.select([], [self._sock], [self._sock], float(timeout))\n        except OSError:\n            self.close()\n            return None\n        if x:\n            self.close()\n            return None\n        return bool(w)\n\n    def connect(self) -> None:\n        \"\"\"\n        Establish TCP connection, optionally upgrade to TLS immediately if\n        secure=True.\n        \"\"\"\n        logger.trace(\n            \"Socket connect IN: host=%s port=%d secure=%s verify=%s\",\n            self.host,\n            self.port,\n            self.secure,\n            self.verify,\n        )\n        self.close()\n\n        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n        try:\n            sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)\n\n            if self.bind_addr is not None or self.bind_port is not None:\n                sock.bind(\n                    (self.bind_addr or \"127.0.0.1\", int(self.bind_port or 0)))\n\n            if self._connect_timeout is not None:\n                sock.settimeout(self._connect_timeout)\n\n            # Establish our connection\n            sock.connect((self.host, self.port))\n\n            # We control I/O blocking explicitly with select()\n            sock.settimeout(None)\n\n            self._sock = sock\n            self._is_tls = False\n            self._had_io = False\n\n            if self.secure:\n                self.start_tls()\n\n            self.local_addr = self._sock.getsockname()\n            self.remote_addr = self._sock.getpeername()\n            self._refresh_wrappers()\n\n            logger.debug(\n                \"Socket connected: local=%s remote=%s tls=%s\",\n                self.local_addr,\n                self.remote_addr,\n                self._is_tls,\n            )\n\n        except Exception as e:\n            with contextlib.suppress(Exception):\n                sock.close()\n\n            self._sock = None\n            self._had_io = False\n            logger.debug(\"Socket connect exception: %s\", e)\n            raise AppriseSocketError(str(e)) from e\n\n    def _server_hostname_for_tls(self) -> str:\n        \"\"\"\n        Determine hostname used for SNI and hostname verification.\n\n        If verify=True and host is an IP address, attempt reverse DNS lookup.\n        \"\"\"\n        host = self.host\n\n        if not self.verify:\n            return host\n\n        try:\n            ipaddress.ip_address(host)\n        except ValueError:\n            return host\n\n        try:\n            name, _, _ = socket.gethostbyaddr(host)\n            return name.rstrip(\".\") if name else host\n        except Exception:\n            return host\n\n    def _build_ssl_context(self) -> ssl.SSLContext:\n        \"\"\"Build SSL context using certifi bundle when verify=True.\"\"\"\n        # Enforce TLS 1.2+ to avoid TLSv1/TLSv1.1 negotiation.\n        # We explicitly enforce TLS >= 1.2 for Python 3.9+ compatibility.\n        if self.verify:\n            import certifi\n\n            ctx = ssl.create_default_context(cafile=certifi.where())\n            ctx.check_hostname = True\n            ctx.verify_mode = ssl.CERT_REQUIRED\n        else:\n            # Still enforce modern TLS even when certificate verification is\n            # disabled, since protocol downgrade is independent of trust.\n            ctx = ssl.create_default_context()\n            ctx.check_hostname = False\n            ctx.verify_mode = ssl.CERT_NONE\n\n        # Enforce TLS 1.2+ to avoid TLSv1/TLSv1.1 negotiation.\n        try:\n            ctx.minimum_version = ssl.TLSVersion.TLSv1_2\n        except Exception:\n            # Fallback for very old Python/OpenSSL combinations\n            with contextlib.suppress(Exception):\n                ctx.options |= ssl.OP_NO_TLSv1\n            with contextlib.suppress(Exception):\n                ctx.options |= ssl.OP_NO_TLSv1_1\n\n        # Disable TLS-level compression (mitigates CRIME-style attacks).\n        with contextlib.suppress(Exception):\n            ctx.options |= ssl.OP_NO_COMPRESSION\n\n        return ctx\n\n    def start_tls(self) -> None:\n        \"\"\"Upgrade an existing TCP connection to TLS.\"\"\"\n        if self._sock is None:\n            raise AppriseSocketError(\"No active connection to upgrade\")\n\n        if self._is_tls:\n            return\n\n        server_hostname = self._server_hostname_for_tls()\n        logger.trace(\"Starting TLS upgrade: sni=%s\", server_hostname)\n\n        try:\n            ctx = self._build_ssl_context()\n            tls_sock = ctx.wrap_socket(\n                self._sock,\n                server_hostname=server_hostname,\n            )\n\n            tls_sock.setblocking(False)\n            self._sock = tls_sock\n            self._is_tls = True\n\n            self.local_addr = self._sock.getsockname()\n            self.remote_addr = self._sock.getpeername()\n            self._refresh_wrappers()\n\n            logger.trace(\n                \"TLS upgrade complete: local=%s remote=%s\",\n                self.local_addr,\n                self.remote_addr,\n            )\n\n        except ssl.SSLError as e:\n            self.close()\n            logger.debug(\"TLS negotiation exception: %s\", e)\n            raise AppriseSocketError(f\"TLS negotiation failed: {e}\") from e\n        except OSError as e:\n            self.close()\n            logger.debug(\"TLS negotiation exception: %s\", e)\n            raise AppriseSocketError(str(e)) from e\n\n    def _attempt_reconnect(\n        self,\n        retries: int,\n        action: str,\n        exc: Exception,\n    ) -> bool:\n        \"\"\"\n        Attempt to reconnect and allow the caller to retry.\n\n        Args:\n            retries: Remaining reconnect attempts permitted (<= 0 disables).\n            action: A short label (e.g. \"read\" or \"write\") for logging.\n            exc: The exception that triggered the reconnect attempt.\n\n        Returns:\n            True if a reconnect was performed and the caller should retry.\n        \"\"\"\n        # Respect the caller's retry budget\n        if int(retries) <= 0:\n            return False\n\n        # Only retry if we have previously completed useful I/O since the last\n        # connect(). This prevents retrying the first failed read/write after\n        # connect.\n        if not self._had_io:\n            return False\n\n        logger.warning(\n            \"Socket %s failed, reconnecting and retrying\", action\n        )\n        logger.debug(\"Socket %s exception: %s\", action, exc)\n\n        try:\n            self.close()\n            self.connect()\n\n        except Exception as e:\n            logger.debug(\"Socket reconnect exception: %s\", e)\n            return False\n\n        return True\n\n    def read(\n        self,\n        max_bytes: int = 32768,\n        blocking: bool = False,\n        timeout: Optional[float] = None,\n        retries: Optional[int] = None,\n    ) -> bytes:\n        \"\"\"\n        Read up to max_bytes bytes.\n\n        blocking=False:\n          - returns immediately with available data, or b\"\" if none\n\n        blocking=True:\n          - waits up to timeout seconds (or instance read timeout if timeout is\n            None), then reads once\n          - if both are None, waits indefinitely\n\n        retries:\n          - number of reconnect attempts permitted if the socket goes stale\n            after prior successful I/O. Defaults to None (which takes value\n            globally passed into the class)\n        \"\"\"\n        if self._sock is None:\n            return b\"\"\n\n        # Compute retry attempts; treat retries=0 as explicit 0\n        retry_count = self.retries if retries is None else int(retries)\n        attempts = max(0, retry_count) + 1\n\n        # Derive wait timeout (None means wait indefinitely)\n        wait_timeout = \\\n            self._read_timeout if timeout is None else float(timeout)\n\n        # We manage readiness via select, socket stays non-blocking\n        self._sock.setblocking(False)\n\n        while attempts:\n            attempts -= 1\n\n            try:\n                if not blocking:\n                    try:\n                        data = self._sock.recv(int(max_bytes))\n                        if data == b\"\":\n                            raise AppriseSocketError(\n                                \"Connection lost during read\")\n                        self._had_io = True\n                        return data\n                    except (BlockingIOError, ssl.SSLWantReadError,\n                            ssl.SSLWantWriteError):\n                        return b\"\"\n\n                # blocking=True path: wait for readability, then recv\n                if wait_timeout is None:\n                    # Wait indefinitely but periodically confirm socket health\n                    while True:\n                        ready = self.can_read(0.5)\n                        if ready is None:\n                            raise AppriseSocketError(\"Socket closed\")\n\n                        if ready:\n                            break\n                else:\n                    ready = self.can_read(wait_timeout)\n                    if not ready:\n                        return b\"\"\n\n                # Even after select says readable, TLS may still raise\n                # WANT_READ/WRITE. Loop until we either receive data, timeout,\n                # or the socket closes.\n                while True:\n                    try:\n                        data = self._sock.recv(int(max_bytes))\n                        if data == b\"\":\n                            raise AppriseSocketError(\n                                \"Connection lost during read\")\n                        self._had_io = True\n                        return data\n\n                    except (ssl.SSLWantReadError, ssl.SSLWantWriteError,\n                            BlockingIOError):\n\n                        if wait_timeout is None:\n                            continue\n\n                        # Avoid busy loop\n                        if not self.can_read(min(0.25, wait_timeout)):\n                            return b\"\"\n\n            except (AppriseSocketError, OSError, ssl.SSLError) as e:\n                # Normalise and log\n                logger.warning(\"Socket read failed\")\n                logger.debug(\"Socket read exception: %s\", e)\n\n                # Only close on hard errors; WANT_READ/WRITE handled above\n                if isinstance(e, OSError) \\\n                        and not isinstance(e, ssl.SSLWantReadError) \\\n                        and not isinstance(e, ssl.SSLWantWriteError):\n                    self.close()\n\n                err: Exception = e\n\n                # Reconnect only if we've had prior useful I/O\n                if self._attempt_reconnect(\n                        retries=attempts,\n                        action=\"read\",\n                        exc=err,\n                ):\n                    # In blocking mode with no timeout (wait indefinitely),\n                    # perform an immediate read attempt after reconnect.\n                    # This avoids relying solely on can_read(), and it keeps\n                    # edge cases (like stale sockets) recoverable.\n                    if blocking and wait_timeout is None \\\n                            and self._sock is not None:\n                        try:\n                            data = self._sock.recv(int(max_bytes))\n\n                            if data == b\"\":\n                                raise AppriseSocketError(\n                                    \"Connection lost during read\")\n\n                            self._had_io = True\n                            return data\n\n                        except (BlockingIOError, ssl.SSLWantReadError,\n                                ssl.SSLWantWriteError):\n                            # No data yet; fall back to retry loop\n                            pass\n\n                    continue\n\n                if isinstance(err, AppriseSocketError):\n                    raise err from None\n                raise AppriseSocketError(str(err)) from err\n\n        raise AppriseSocketError(\"Socket read failed\")\n\n    def write(\n        self,\n        data: bytes,\n        flush: bool = True,\n        timeout: Optional[float] = None,\n        retries: Optional[int] = None,\n    ) -> int:\n        \"\"\"\n        Write bytes to the socket.\n\n        timeout:\n          - if None, uses instance read timeout\n          - if both are None, blocks until complete\n\n        retries:\n          - number of reconnect attempts permitted if the socket goes stale\n            after prior successful I/O. Defaults to None (which takes value\n            globally passed into the class)\n        \"\"\"\n        if self._sock is None:\n            raise AppriseSocketError(\"No active connection\")\n\n        if not isinstance(data, (bytes, bytearray, memoryview)):\n            raise AppriseInvalidData(\"write() expects bytes-like data\")\n\n        # Loop-based retry avoids recursion and keeps state obvious\n        retry_count = self.retries if retries is None else int(retries)\n        attempts = max(0, retry_count) + 1\n\n        while attempts:\n            attempts -= 1\n\n            view = memoryview(data)\n            total_sent = 0\n\n            op_timeout = (\n                self._read_timeout if timeout is None else float(timeout)\n            )\n            deadline = (\n                None\n                if op_timeout is None\n                else (time.monotonic() + op_timeout)\n            )\n\n            try:\n                self._sock.setblocking(deadline is None)\n\n                while total_sent < len(view):\n                    if deadline is not None:\n                        remaining = deadline - time.monotonic()\n                        if remaining <= 0:\n                            raise AppriseSocketError(\n                                \"Timed out during write\"\n                            )\n                        writable = self.can_write(remaining)\n                        if not writable:\n                            raise AppriseSocketError(\n                                \"Timed out waiting for writable socket\"\n                            )\n\n                    sent = self._sock.send(view[total_sent:])\n                    if sent <= 0:\n                        raise AppriseSocketError(\n                            \"Connection lost during write\"\n                        )\n                    total_sent += sent\n\n                if flush and self._wfile is not None:\n                    self._wfile.flush()\n\n                if total_sent > 0:\n                    self._had_io = True\n\n                return total_sent\n\n            except (AppriseSocketError, OSError) as e:\n                logger.warning(\"Socket write failed\")\n                logger.debug(\"Socket write exception: %s\", e)\n\n                # Normalise: any OSError implies the socket is toast\n                if isinstance(e, OSError):\n                    self.close()\n\n                if self._attempt_reconnect(\n                    retries=attempts,\n                    action=\"write\",\n                    exc=e,\n                ):\n                    continue\n\n                if isinstance(e, AppriseSocketError):\n                    raise\n                raise AppriseSocketError(str(e)) from e\n\n        raise AppriseSocketError(\"Socket write failed\")\n"
  },
  {
    "path": "apprise/utils/templates.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport json\nimport re\n\n\nclass TemplateType:\n    \"\"\"Defines the different template types we can perform parsing on.\"\"\"\n\n    # RAW does nothing at all to the content being parsed\n    # data is taken at it's absolute value\n    RAW = \"raw\"\n\n    # Data is presumed to be of type JSON and is therefore escaped\n    # if required to do so (such as single quotes)\n    JSON = \"json\"\n\n\ndef apply_template(template, app_mode=TemplateType.RAW, **kwargs):\n    \"\"\"Takes a template in a str format and applies all of the keywords and\n    their values to it.\n\n    The app$mode is used to dictact any pre-processing that needs to take place\n    to the escaped string prior to it being placed.  The idea here is for\n    elements to be placed in a JSON response for example should be escaped\n    early in their string format.\n\n    The template must contain keywords wrapped in in double squirly braces like\n    {{keyword}}.  These are matched to the respected kwargs passed into this\n    function.\n\n    If there is no match found, content is not swapped.\n    \"\"\"\n\n    def _escape_raw(content):\n        # No escaping necessary\n        return content\n\n    def _escape_json(content):\n        # remove surounding quotes\n        return json.dumps(content)[1:-1]\n\n    # Our escape function\n    fn = _escape_json if app_mode == TemplateType.JSON else _escape_raw\n\n    lookup = [re.escape(x) for x in kwargs]\n\n    # Compile this into a list\n    mask_r = re.compile(\n        re.escape(\"{{\")\n        + r\"\\s*(\"\n        + \"|\".join(lookup)\n        + r\")\\s*\"\n        + re.escape(\"}}\"),\n        re.IGNORECASE,\n    )\n\n    # we index 2 characters off the head and 2 characters from the tail\n    # to drop the '{{' and '}}' surrounding our match so that we can\n    # re-index it back into our list\n    return mask_r.sub(lambda x: fn(kwargs[x.group()[2:-2].strip()]), template)\n"
  },
  {
    "path": "apprise/utils/time.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport contextlib\nfrom datetime import timezone as _tz\nfrom typing import Optional\nfrom zoneinfo import ZoneInfo, ZoneInfoNotFoundError\n\nfrom ..logger import logger\n\n\ndef zoneinfo(name: str) -> Optional[ZoneInfo]:\n    \"\"\"\n    More forgiving ZoneInfo instantiation\n    - Accepts lower/upper case\n    - Normalises common UTC variants\n    \"\"\"\n    if not isinstance(name, str):\n        return None\n\n    raw = name.strip()\n    if not raw:\n        return None\n\n    # Windows-safe: accept UTC family even without tzdata\n    if raw.lower() in {\n            \"utc\", \"z\", \"gmt\", \"etc/utc\", \"etc/gmt\", \"gmt0\", \"utc0\"}:\n        return _tz.utc\n\n    # Try exact match first\n    try:\n        return ZoneInfo(name)\n\n    except ZoneInfoNotFoundError:\n        pass\n\n    # Try case-insensitive match across available keys\n    from zoneinfo import available_timezones\n    lowered = name.lower().strip()\n    for zone in available_timezones():\n        full_zone = zone.lower()\n        if full_zone == lowered:\n            return ZoneInfo(zone)\n\n        with contextlib.suppress(IndexError):\n\n            # Break our zones and enforce limit\n            zones = full_zone.split(\"/\")[1:3]\n\n            # Possible we'll throw an index error here and that's okay\n            location = zones[-1] if len(zones) == 1 else \"/\".join(zones)\n            if location and location == lowered:\n                return ZoneInfo(zone)\n\n    logger.warning(\"Unknown timezone specified: %s\", name)\n    return None\n"
  },
  {
    "path": "babel.cfg",
    "content": "[python: **.py]\nencoding = utf-8\n"
  },
  {
    "path": "bin/README.md",
    "content": "# 🛠️ Apprise Development Guide\n\nWelcome! This guide helps you contribute to Apprise with confidence. It outlines\nhow to set up your local environment, run tests, lint your code, and build \npackages — all using modern tools like [Tox](https://tox.readthedocs.io/) and \n[Ruff](https://docs.astral.sh/ruff/).\n\n---\n\n## 🚀 Getting Started\n\nSet up your local development environment using Tox:\n\n```bash\n# Install Tox\npython -m pip install tox\n```\n\nTox manages dependencies, linting, testing, and builds — no need to manually \ninstall `requirements-dev.txt`.\n\n---\n\n## 🧪 Running Tests\n\nUse the `qa` environment for full testing and plugin coverage:\n\n```bash\ntox -e qa\n```\n\nTo focus on specific tests (e.g., email-related):\n\n```bash\ntox -e qa -- -k email\n```\n\nTo run a minimal dependency test set:\n\n```bash\ntox -e minimal\n```\n\n---\n\n## 🧹 Linting and Formatting\n\nApprise uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting.\nThis is configured via `pyproject.toml`.\n\nRun linting:\n\n```bash\ntox -e lint\n```\n\nFix formatting automatically (where possible):\n\n```bash\ntox -e format\n```\n\n> Linting runs automatically on all PRs that touch Python files via GitHub \n> Actions and will fail builds on violation.\n\n---\n\n## ✅ Pre-Commit Check (Recommended)\n\nBefore pushing or creating a PR, validate your work with:\n\n```bash\ntox -e lint,qa\n```\n\nOr use a combined check shortcut (if defined):\n\n```bash\ntox -e checkdone\n```\n\nThis ensures your changes are linted, tested, and PR-ready.\n\n---\n\n## 📨 CLI Testing\n\nYou can run the `apprise` CLI using your local code without installation or run within docker containers:\n\n```bash\n# From the root of the repo\n./bin/apprise -t \"Title\" -b \"Body\" mailto://user:pass@example.com\n```\n\nAlternatively you can continue to use the `tox` environment:\n\n\n```bash\n# syntax tox -e apprise -- [options], e.g.:\ntox -e apprise -- -vv -b \"test body\" -t \"test title\" mailto://credentials\n```\n\nOptionally, add the `bin/apprise` to tests your changes\n\n```bash\nbin/apprise -vv -b \"test body\" -t \"test title\" <schema>\n```\n\n---\n\n## 📦 RPM Build & Verification\n\nApprise supports RPM packaging for Fedora and RHEL-based systems. Use Docker \nto safely test builds:\n\n```bash\n# Build RPM for EL9\ndocker-compose run --rm rpmbuild.el9 /apprise/bin/build-rpm.sh\n\n# Build RPM for EL10\ndocker-compose run --rm rpmbuild.el10 /apprise/bin/build-rpm.sh\n\n# Build RPM for Fedora 42\ndocker-compose run --rm rpmbuild.f42 /apprise/bin/build-rpm.sh\n```\n\n## 📦 Specific Environment Emulation\n\nYou can also emulate your own docker environment and just test/build inside that\n```bash\n# Python v3.9 Testing\ndocker-compose run --rm test.py39 bash\n\n# Python v3.10 Testing\ndocker-compose run --rm test.py310 bash\n\n# Python v3.11 Testing\ndocker-compose run --rm test.py311 bash\n\n# Python v3.12 Testing\ndocker-compose run --rm test.py312 bash\n```\nOnce you've entered one of these environments, you can leverage the following command to work with:\n\n1. `bin/test.sh`: runs the full test suite (same as `tox -e qa`) but without coveage\n1. `bin/checkdone.sh`: runs the full test suite (same as `tox -e qa`)\n1. `bin/apprise`: launches the Apprise CLI using the local build (same as `tox -e apprise`)\n1. `ruff check . --fix`: auto-formats the codebase (same as `tox -e format`)\n1. `ruff check .`: performs lint-only validation (same as `tox -e lint`)\n1. `coverage run --source=apprise -m pytest tests`: manual test execution with coverage\n\nThe only advantage of this route is the overhead associated with each `tox` call is gone (faster responses).  Otherwise just utilizing the `tox` commands can sometimes be easier.\n\n## 🧪 GitHub Actions\n\nGitHub Actions runs:\n- ✅ Full test suite with coverage\n- ✅ Linting (using Ruff)\n- ✅ Packaging and validation\n\nLinting **must pass** before PRs can be merged.\n\n---\n\n## 🧠 Developer Tips\n\n- Add new plugins by following [`demo.py`](https://github.com/caronc/apprise/blob/master/apprise/plugins/demo.py) as a template.\n- Write unit tests under `tests/` using the `AppriseURLTester` pattern.\n- All new plugins must include test coverage and pass linting.\n"
  },
  {
    "path": "bin/apprise",
    "content": "#!/usr/bin/env python\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"\nThis is a debug tool that allows one to test the apprise source code just\nchecked out. The script works out of the ./devel directory and will also work\nif you just copy it back on directory and run it from the root.\n\"\"\"\nimport sys\nfrom os import getcwd\nfrom os.path import join\nfrom os.path import abspath\nfrom os.path import dirname\n\n#\n# Update path\n#\n\n# First assume we might be in the ./bin directory\nsys.path.insert(\n    0, join(dirname(dirname(abspath(__file__)))))\n\n# The user might have copied the apprise script back one directory\n# so support this too..\nsys.path.insert(\n    0, join(dirname(abspath(__file__))))\n\n# We can also use the current directory we're standing in as a last\n# resort\nsys.path.insert(0, join(getcwd()))\n\n# Apprise tool now importable\nfrom apprise.cli import main\nimport logging\n\n\nif __name__ == \"__main__\":\n    # Logging\n    ch = logging.StreamHandler(sys.stdout)\n    logger = logging.getLogger(__name__)\n\n    formatter = logging.Formatter(\n        '%(asctime)s - %(levelname)s - %(message)s')\n    ch.setFormatter(formatter)\n    logger.addHandler(ch)\n    logging.getLogger('apprise').setLevel(logger.getEffectiveLevel())\n\n    main()\n    exit(0)\n"
  },
  {
    "path": "bin/build-rpm.sh",
    "content": "#!/bin/bash\n# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#!/usr/bin/env bash\nset -euo pipefail\n\n# Set Apprise root directory\nAPPRISE_DIR=\"${APPRISE_DIR:-/apprise}\"\nDIST_DIR=\"${DIST_DIR:-$PWD/dist/rpm}\"\nSOURCES_DIR=\"$APPRISE_DIR/SOURCES\"\nSRPM_DIR=\"$DIST_DIR/\"\n\nPYTHON=python3\nTOX=\"tox -c $APPRISE_DIR/tox.ini\"\n\nif [ ! -d \"$DIST_DIR\" ]; then\n   echo \"==> Cleaning previous builds\"\n   $TOX -e clean --notest\n\n   echo \"==> Linting RPM spec\"\n       rpmlint \"$APPRISE_DIR/packaging/redhat/python-apprise.spec\"\n\n   echo \"==> Generating man pages\"\n   ronn --roff --organization=\"Chris Caron <lead2gold@gmail.com>\" \\\n       \"$APPRISE_DIR/packaging/man/apprise.md\"\n\n   echo \"==> Extracting translations\"\n   $TOX -e i18n\n\n   echo \"==> Compiling translations\"\n   $TOX -e compile\n\n   echo \"==> Building source distribution\"\n   $TOX -e build-sdist\nfi\n\nVERSION=$(rpmspec -q --qf \"%{version}\\n\" \"$APPRISE_DIR/packaging/redhat/python-apprise.spec\" | head -n1)\nTARBALL=\"$APPRISE_DIR/dist/apprise-${VERSION}.tar.gz\"\n\nif [[ ! -f \"$TARBALL\" ]]; then\n  echo \"❌ Tarball not found: $TARBALL\"\n  exit 1\nfi\n\necho \"==> Preparing SOURCES directory\"\nmkdir -p \"$SOURCES_DIR\"\ncp \"$TARBALL\" \"$SOURCES_DIR/\"\nfind $APPRISE_DIR/packaging/redhat/ -iname '*.patch' -exec cp {} \"$SOURCES_DIR\" \\;\n\necho \"==> Building RPM (source and binary)\"\nmkdir -p \"$DIST_DIR\"\nrpmbuild --define \"_topdir $APPRISE_DIR\" \\\n         --define \"_sourcedir $SOURCES_DIR\" \\\n         --define \"_specdir $APPRISE_DIR/packaging/redhat\" \\\n         --define \"_srcrpmdir $DIST_DIR\" \\\n         --define \"_rpmdir $DIST_DIR\" \\\n         -ba \"$APPRISE_DIR/packaging/redhat/python-apprise.spec\"\n\necho \"✅ RPM build completed successfully\"\necho \"📦 Artifacts:\"\nfind \"$DIST_DIR\" -type f -name \"*.rpm\"\n"
  },
  {
    "path": "bin/checkdone.sh",
    "content": "#!/bin/bash\n# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Absolute path to this script, e.g. /home/user/bin/foo.sh\nSCRIPT=$(readlink -f \"$0\")\n\n# Absolute path this script is in, thus /home/user/bin\nSCRIPTPATH=$(dirname \"$SCRIPT\")\n\nPYTHONPATH=\"\"\n\nFOUNDROOT=1\nif [ -f \"$(dirname $SCRIPTPATH)/pyproject.toml\" ]; then\n   pushd \"$(dirname $SCRIPTPATH)\" &>/dev/null\n   FOUNDROOT=$?\n   PYTHONPATH=\"$(dirname $SCRIPTPATH)\"\n\nelif [ -f \"$SCRIPTPATH/pyproject.toml\" ]; then\n   pushd \"$SCRIPTPATH\" &>/dev/null\n   FOUNDROOT=$?\n   PYTHONPATH=\"$SCRIPTPATH\"\nfi\n\nif [ $FOUNDROOT -ne 0 ]; then\n   echo \"Error: Could not locate Apprise pyproject.toml file.\"\n   exit 1\nfi\n\n# Tidy previous reports (if present)\n[ -d .coverage-reports ] && rm -rf .coverage-reports\n\n# This is a useful tool for checking for any lint errors and additionally\n# checking the overall coverage.\nwhich ruff &>/dev/null\n[ $? -ne 0 ] && \\\n   echo \"Missing ruff; make sure it is installed:\" && \\\n   echo \"  >  pip install ruff\" && \\\n   exit 1\n\nwhich coverage &>/dev/null\n[ $? -ne 0 ] && \\\n   echo \"Missing coverage; make sure it is installed:\" &&\n   echo \"  >  pip install pytest-cov coverage\" && \\\n   exit 1\n\necho \"Performing PEP8 check...\"\nLANG=C.UTF-8 PYTHONPATH=$PYTHONPATH ruff check\nif [ $? -ne 0 ]; then\n   echo \"PEP8 check failed\"\n   exit 1\nfi\necho \"PEP8 check succeeded; no errors found! :)\"\necho\n\n# Run our unit test coverage check\necho \"Running test coverage check...\"\npushd $PYTHONPATH &>/dev/null\nif [ ! -z \"$@\" ]; then\n   LANG=C.UTF-8 PYTHONPATH=$PYTHONPATH coverage run -m pytest -vv -k \"$@\"\n   RET=$?\n\nelse\n   LANG=C.UTF-8 PYTHONPATH=$PYTHONPATH coverage run -m pytest -vv\n   RET=$?\nfi\n\nif [ $RET -ne 0 ]; then\n   echo \"Tests failed.\"\n   exit 1\nfi\n\n# Build our report\nLANG=C.UTF-8 PYTHONPATH=$PYTHONPATH coverage combine\n\n# Prepare XML Reference\nLANG=C.UTF-8 PYTHONPATH=$PYTHONPATH coverage xml\n\n# Print our report\nLANG=C.UTF-8 PYTHONPATH=$PYTHONPATH coverage report --show-missing\n"
  },
  {
    "path": "bin/test.sh",
    "content": "#!/bin/bash\n# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\nPYTEST=$(which py.test 2>/dev/null)\n# Support different distributions\n[ -z \"$PYTEST\" ] && PYTEST=$(which py.test-3 2>/dev/null)\n# This script can basically be used to test individual tests that have\n# been created. Just run the to run all tests:\n#    ./devel/test.sh\n\n# to key in on a specific test type:\n#     ./devel/test.sh <keyword>\n\n# Absolute path to this script, e.g. /home/user/bin/foo.sh\nSCRIPT=$(readlink -f \"$0\")\n\n# Absolute path this script is in, thus /home/user/bin\nSCRIPTPATH=$(dirname \"$SCRIPT\")\n\nPYTHONPATH=\"\"\n\nif [ -f \"$(dirname $SCRIPTPATH)/pyproject.toml\" ]; then\n   PYTHONPATH=\"$(dirname $SCRIPTPATH)\"\n\nelif [ -f \"$SCRIPTPATH/pyproject.toml\" ]; then\n   PYTHONPATH=\"$SCRIPTPATH\"\n\nelse\n   echo \"Error: Could not locate apprise pyproject.toml file.\"\n   exit 1\nfi\n\nif [ ! -x $PYTEST ]; then\n   echo \"Error: $PYTEST was not found; make sure it is installed: 'pip3 install pytest'\"\n   exit 1\nfi\n\npushd $PYTHONPATH &>/dev/null\nif [ ! -z \"$@\" ]; then\n   LANG=C.UTF-8 PYTHONPATH=$PYTHONPATH $PYTEST -k \"$@\"\n   exit $?\n\nelse\n   LANG=C.UTF-8 PYTHONPATH=$PYTHONPATH $PYTEST\n   exit $?\nfi\n"
  },
  {
    "path": "dev-requirements.txt",
    "content": "#\n# Note: This file is being kept for backwards compatibility with\n#       legacy systems that point here.  All future changes should\n#       occur in pyproject.toml.  Contents of this file can be found\n#       in [project.optional-dependencies].dev\n\ncoverage\nmock\npytest\npytest-cov\npytest-mock\nruff\nbabel\nvalidate-pyproject\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  test.py39:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.py39\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n  test.py310:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.py310\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n  test.py311:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.py311\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n  test.py312:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.py312\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n  rpmbuild.el9:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.el9\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n  rpmbuild.el10:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.el10\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n  rpmbuild.f42:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.f42\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n  rpmbuild.rawhide:\n    build:\n      context: .\n      dockerfile: tests/docker/Dockerfile.rawhide\n    volumes:\n      - ./:/apprise\n    working_dir: /apprise\n    user: \"1000:1000\"\n\n#\n# Every Day testing\n#\n# Sample testing:\n# -> docker-compose run --rm test.py312 bash\n# bin/apprise -\n# tox -e checkdone\n#\n# Run a set of tests for just a certain section\n#  docker-compose run --rm test.py312 tox -e qa -- -k fcm\n#\n# Or just run all the tests in python 3.12\n#  docker-compose run --rm test.py312 tox -e qa\n#\n# Want to run the whole test suite:\n#\n#\n# RPM Building\n#\n\n# el9\n#  - docker-compose run --rm rpmbuild.el9 /apprise/bin/build-rpm.sh\n# el10\n#  - docker-compose run --rm rpmbuild.el10 /apprise/bin/build-rpm.sh\n# f42 (Fedora)\n#  - docker-compose run --rm rpmbuild.f42 /apprise/bin/build-rpm.sh\n"
  },
  {
    "path": "packaging/README.md",
    "content": "## Packaging\nThis directory contains any supporting files to grant usage of Apprise in various distributions.\n\nLet me know if you'd like to help me host on more platforms or can offer to do it yourself!\n\n### RPM Based Packages\n* [EPEL](https://fedoraproject.org/wiki/EPEL) based distributions are only supported if they are of v9 or higher. This includes:\n   * Red Hat 10.x (or higher)\n   * Scientific OS 10.x (or higher)\n   * Oracle Linux 10.x (or higher)\n   * Rocky Linux 10.x (or higher)\n   * Alma Linux 110.x (or higher)\n   * Fedora 29 (or higher)\n\nProvided you are connected to the [EPEL repositories](https://fedoraproject.org/wiki/EPEL), the following will just work for you:\n```bash\n# python3-apprise: contains all you need to develop with apprise\n# apprise: provides the 'apprise' administrative tool\ndnf install python3-apprise apprise\n```\n\nYou can build your own rpm packges with the following:\n* EPEL10 (Rocky/RedHat/Oracle Linux)\n   ```bash\n   tox -e build-el10-rpm\n   ```\n\n* EPEL9 (Rocky/RedHat/Oracle Linux)\n   ```bash\n   tox -e build-el9-rpm\n   ```\n\n* Fedora 42\n   ```bash\n   tox -e build-f42-rpm\n   ```\n\n* Fedora Rawhide\n   ```bash\n   tox -e build-rawhide-rpm\n   ```\n\n## Man Pages Information\nThe man page were generated using [Ronn](http://github.com/rtomayko/ronn/tree/0.7.3).\n - Content is directly written to entries in the **man/\\*.md** files _following the\n   [the format structure available on the Ronn site](https://github.com/rtomayko/ronn/blob/master/man/ronn.1.ronn)_.\n - Then the following is executed `ronn --roff man/apprise.md` to produce the man/apprise.1 which is used by distributions.\n\nThe easiest way to generate the new man page (after updating the `.md` file is:\n```bash\n# rebuild man page\ntox -e man\n```\n"
  },
  {
    "path": "packaging/i18n_normalize.sh",
    "content": "#!/usr/bin/sh\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# Operations performed:\n#   1) Merge duplicates: msguniq --use-first\n#   2) Drop obsolete entries: msgattrib --no-obsolete\n#   3) Validate: msgfmt --check\n#\n# This script is intended for developer/build tooling (tox/release).\nset -eu\n\nROOT=\"${1:-apprise/i18n}\"\n\ncommand -v msguniq   >/dev/null 2>&1 || { echo \"Missing msguniq (gettext)\"; exit 1; }\ncommand -v msgattrib >/dev/null 2>&1 || { echo \"Missing msgattrib (gettext)\"; exit 1; }\ncommand -v msgfmt    >/dev/null 2>&1 || { echo \"Missing msgfmt (gettext)\"; exit 1; }\n\n# Find .po files; exit cleanly if none exist\nPO_FILES=$(find \"${ROOT}\" -type f -name '*.po' 2>/dev/null || true)\n[ -n \"${PO_FILES}\" ] || exit 0\n\nfor po in ${PO_FILES}; do\n    # Merge duplicates deterministically (tolerates duplicates by design)\n    msguniq --use-first -o \"${po}\" \"${po}\"\n    # Optionally drop obsolete entries afterwards\n    msgattrib --no-obsolete -o \"${po}\" \"${po}\"\n    # Validate\n    msgfmt --check -o /dev/null \"${po}\"\ndone\n\necho \"Normalised and validated PO files under ${ROOT}\"\n"
  },
  {
    "path": "packaging/man/apprise.1",
    "content": ".\\\" generated with Ronn-NG/v0.10.1\n.\\\" http://github.com/apjanke/ronn-ng/tree/0.10.1\n.TH \"APPRISE\" \"1\" \"March 2026\" \"Chris Caron <lead2gold@gmail.com>\"\n.SH \"NAME\"\n\\fBapprise\\fR \\- Push Notifications that work with just about every platform!\n.SH \"SYNOPSIS\"\n\\fBapprise\\fR [\\fIoptions\\fR\\|\\.\\|\\.\\|\\.] \\fIservice\\-url\\fR\\|\\.\\|\\.\\|\\.\n.br\n\\fBapprise\\fR storage [\\fIoptions\\fR\\|\\.\\|\\.\\|\\.] [\\fIaction\\fR] \\fIurl\\-id\\fR\\|\\.\\|\\.\\|\\.\n.br\n.SH \"DESCRIPTION\"\n\\fBApprise\\fR allows you to send a notification to \\fIalmost all\\fR of the most popular notification services available to us today such as: Discord, Telegram, Pushbullet, Slack, Twitter, etc\\.\n.IP \"\\(bu\" 4\nOne notification library to rule them all\\.\n.IP \"\\(bu\" 4\nA common and intuitive notification syntax\\.\n.IP \"\\(bu\" 4\nSupports the handling of images (to the notification services that will accept them)\\.\n.IP \"\\(bu\" 4\nIt's incredibly lightweight\\.\n.IP \"\\(bu\" 4\nAmazing response times because all messages sent asynchronously\\.\n.IP \"\" 0\n.SH \"OPTIONS\"\nThe Apprise options are as follows:\n.P\n\\fB\\-b\\fR, \\fB\\-\\-body=\\fR\\fIVALUE\\fR: Specify the message body\\. If no body is specified then content is read from \\fIstdin\\fR\\.\n.P\n\\fB\\-t\\fR, \\fB\\-\\-title=\\fR\\fIVALUE\\fR: Specify the message title\\. This field is completely optional\\.\n.P\n\\fB\\-c\\fR, \\fB\\-\\-config=\\fR\\fICONFIG\\-URL\\fR: Specify one or more configuration locations\\.\n.P\n\\fB\\-a\\fR, \\fB\\-\\-attach=\\fR\\fIATTACH\\-URL\\fR: Specify one or more file attachment locations\\.\n.P\n\\fB\\-P\\fR, \\fB\\-\\-plugin\\-path=\\fR\\fIPATH\\fR: Specify a path to scan for custom notification plugin support\\. You can create your own notification by simply creating a Python file that contains the \\fB@notify(\"schema\")\\fR decorator\\.\n.P\nYou can optionally choose to specify more than one \\fB\\-\\-plugin\\-path\\fR (\\fB\\-P\\fR) to increase the modules included\\.\n.P\n\\fB\\-n\\fR, \\fB\\-\\-notification\\-type=\\fR\\fIVALUE\\fR: Specify the message type (default=info)\\. Possible values are \"info\", \"success\", \"failure\", and \"warning\"\\.\n.P\n\\fB\\-i\\fR, \\fB\\-\\-input\\-format=\\fR\\fIVALUE\\fR: Specify the input message format (default=text)\\. Possible values are \"text\", \"html\", and \"markdown\"\\.\n.P\n\\fB\\-T\\fR, \\fB\\-\\-theme=\\fR\\fIVALUE\\fR: Specify the default theme\\.\n.P\n\\fB\\-g\\fR, \\fB\\-\\-tag=\\fR\\fIVALUE\\fR: Specify one or more tags to filter which services to notify:\n.IP \"\\(bu\" 4\n\\fB\\-g \"tagA\" \\-g \"tagB\"\\fR: Match tagA \\fBOR\\fR tagB (Union)\\.\n.IP \"\\(bu\" 4\n\\fB\\-g \"tagA,tagB\"\\fR: Match tagA \\fBAND\\fR tagB (Strict)\\.\n.IP \"\\(bu\" 4\n\\fB\\-g \"all\"\\fR: Notify \\fBALL\\fR services\\.\n.IP \"\\(bu\" 4\n\\fB(Omitted)\\fR: Notify \\fBuntagged\\fR services only\\.\n.IP \"\" 0\n.P\n\\fB\\-Da\\fR, \\fB\\-\\-disable\\-async\\fR: Send notifications synchronously (one after the other) instead of all at once\\.\n.P\n\\fB\\-R\\fR, \\fB\\-\\-recursion\\-depth\\fR\\fIINTEGER\\fR: The number of recursive import entries that can be loaded from within Apprise configuration\\. By default this is set to 1\\. If this is set to zero, then import statements found in any configuration is ignored\\.\n.P\n\\fB\\-e\\fR, \\fB\\-\\-interpret\\-escapes\\fR Enable interpretation of backslash escapes\\. For example, this would convert sequences such as \\en and \\er to their respected ascii new\\-line and carriage return characters prior to the delivery of the notification\\.\n.P\n\\fB\\-j\\fR, \\fB\\-\\-interpret\\-emojis\\fR Enable interpretation of emoji strings\\. For example, this would convert sequences such as :smile: or :grin: to their respected unicode emoji character\\.\n.P\n\\fB\\-S\\fR, \\fB\\-\\-storage\\-path=\\fR\\fIPATH\\fR: Specify the path to the persistent storage caching location\n.P\n\\fB\\-SM\\fR, \\fB\\-\\-storage\\-mode=\\fR\\fIMODE\\fR: Specify the persistent storage operational mode\\. Possible values are \"auto\", \"flush\", and \"memory\"\\. The default is \"auto\" if not specified\\.\n.P\n\\fB\\-SPD\\fR, \\fB\\-\\-storage\\-prune\\-days=\\fR\\fIINTEGER\\fR: Define the number of days the storage prune should run using\\. Setting this to zero (0) will eliminate all accumulated content\\. By default this value is 30 (days)\\.\n.P\n\\fB\\-SUL\\fR, \\fB\\-\\-storage\\-uid\\-length=\\fR\\fIINTEGER\\fR: Define the number of unique characters to store persistent cache in\\. By default this value is 8 (characters)\\.\n.P\n\\fB\\-d\\fR, \\fB\\-\\-dry\\-run\\fR: Perform a trial run but only prints the notification services to\\-be triggered to \\fBstdout\\fR\\. Notifications are never sent using this mode\\.\n.P\n\\fB\\-l\\fR, \\fB\\-\\-details\\fR Prints details about the current services supported by Apprise\\.\n.P\n\\fB\\-v\\fR, \\fB\\-\\-verbose\\fR: The more of these you specify, the more verbose the output is\\. e\\.g: \\-vvvv\n.P\n\\fB\\-D\\fR, \\fB\\-\\-debug\\fR: A debug mode; useful for troubleshooting\\.\n.P\n\\fB\\-V\\fR, \\fB\\-\\-version\\fR: Display the apprise version and exit\\.\n.P\n\\fB\\-h\\fR, \\fB\\-\\-help\\fR: Show this message and exit\\.\n.SH \"PERSISTENT STORAGE\"\nPersistent storage by default writes to the following location unless the environment variable \\fBAPPRISE_STORAGE_PATH\\fR overrides it and/or \\fB\\-\\-storage\\-path\\fR (\\fB\\-SP\\fR) is specified to override it:\n.IP \"\" 4\n.nf\n~/\\.local/share/apprise/cache\n.fi\n.IP \"\" 0\n.P\nTo utilize the persistent storage \\fIhttps://appriseit\\.com/cli/persistent\\-storage/\\fR element associated with Apprise, simply specify the keyword \\fBstorage\\fR\n.IP \"\" 4\n.nf\n$ apprise storage\n.fi\n.IP \"\" 0\n.P\nThe \\fBstorage\\fR action has the following sub actions:\n.P\n\\fBlist\\fR: List all of the detected persistent storage elements and their state (\\fBstale\\fR, \\fBactive\\fR, or \\fBunused\\fR)\\. This is the default action if nothing further is identified\\.\n.P\n\\fBprune\\fR: Removes all persistent storage that has not been referenced for more than 30 days\\. You can optionally set the \\fB\\-\\-storage\\-prune\\-days\\fR to alter this default value\\.\n.P\n\\fBclean\\fR: Removes all persistent storage regardless of age\\.\n.SH \"EXIT STATUS\"\n\\fBapprise\\fR exits with a status of:\n.IP \"\\(bu\" 4\n\\fB0\\fR if all of the notifications were sent successfully\\.\n.IP \"\\(bu\" 4\n\\fB1\\fR if one or more notifications could not be sent\\.\n.IP \"\\(bu\" 4\n\\fB2\\fR if there was an error specified on the command line such as not providing an valid argument\\.\n.IP \"\\(bu\" 4\n\\fB3\\fR if there was one or more Apprise Service URLs successfully loaded but none could be notified due to user filtering (via tags)\\.\n.IP \"\" 0\n.SH \"SERVICE URLS\"\nThere are too many service URL and combinations to list here\\. It's best to visit the Apprise GitHub page \\fIhttps://appriseit\\.com/services/\\fR and see what's available\\. Also try out the Apprise URL Builder \\fIhttps://appriseit\\.com/tools/url\\-builder/\\fR to easily construct the URLS this tool supports and works with\\.\n.P\nThe \\fBenvironment variable\\fR of \\fBAPPRISE_URLS\\fR (comma/space delimited) can be specified to provide the default set of URLs you wish to notify if none are otherwise specified\\.\n.SH \"EXAMPLES\"\nSend a notification to as many servers as you want to specify as you can easily chain them together:\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-t \"my title\" \\-b \"my notification body\" \\e\n   \"mailto://myemail:mypass@gmail\\.com\" \\e\n   \"pbul://o\\.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b\"\n.fi\n.IP \"\" 0\n.P\nIf you don't specify a \\fB\\-\\-body\\fR (\\fB\\-b\\fR) then stdin is used allowing you to use the tool as part of your every day administration:\n.IP \"\" 4\n.nf\n$ cat /proc/cpuinfo | apprise \\-vv \\-t \"cpu info\" \\e\n    \"mailto://myemail:mypass@gmail\\.com\"\n.fi\n.IP \"\" 0\n.P\nLoad in a configuration file which identifies all of your notification service URLs and notify them all:\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-t \"my title\" \\-b \"my notification body\" \\e\n   \\-\\-config=~/apprise\\.yml\n.fi\n.IP \"\" 0\n.P\nLoad in a configuration file from a remote server that identifies all of your notification service URLs and only notify the ones tagged as \\fIdevops\\fR\\.\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-t \"my title\" \\-b \"my notification body\" \\e\n   \\-\\-config=https://localhost/my/apprise/config \\e\n   \\-t devops\n.fi\n.IP \"\" 0\n.P\n\\fBTagging Logic Examples:\\fR\n.P\nNotify any service tagged with \"devops\" OR \"admin\" (Union):\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-t \"Union Test\" \\e\n   \\-\\-config=~/apprise\\.yml \\e\n   \\-g devops \\-g admin\n.fi\n.IP \"\" 0\n.P\nNotify only services tagged with BOTH \"devops\" AND \"critical\" (Intersection):\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-t \"Intersection Test\" \\e\n   \\-\\-config=~/apprise\\.yml \\e\n   \\-g devops,critical\n.fi\n.IP \"\" 0\n.P\nInclude an attachment:\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-t \"School Assignment\" \\-b \"See attached\" \\e\n   \\-\\-attach=Documents/FinalReport\\.docx\n.fi\n.IP \"\" 0\n.P\nList all of the notifications loaded:\n.IP \"\" 4\n.nf\n$ apprise \\-\\-dry\\-run \\-\\-tag=all\n.fi\n.IP \"\" 0\n.P\nList all of the details around the current persistent storage setup:\n.IP \"\" 4\n.nf\n$ apprise storage list\n.fi\n.IP \"\" 0\n.P\nPrune all persistent storage that has not been referenced for at least 10 days or more\n.IP \"\" 4\n.nf\n$ apprise storage prune \\-\\-storage\\-prune\\-days=10\n.fi\n.IP \"\" 0\n.SH \"CUSTOM PLUGIN/NOTIFICATIONS\"\nApprise can additionally allow you to define your own custom \\fBschema://\\fR entries that you can trigger on and call services you've defined\\.\n.P\nBy default \\fBapprise\\fR looks in the following local locations for custom plugin files and loads them:\n.IP \"\" 4\n.nf\n~/\\.apprise/plugins\n~/\\.config/apprise/plugins\n/var/lib/apprise/plugins\n.fi\n.IP \"\" 0\n.P\nThe \\fBenvironment variable\\fR of \\fBAPPRISE_PLUGIN_PATH\\fR can be specified to override the list identified above with one of your own\\. use a semi\\-colon (\\fB;\\fR), line\\-feed (\\fB\\en\\fR), and/or carriage return (\\fB\\er\\fR) to delimit multiple entries\\.\n.P\nSimply create your own python file with the following bare minimum content in it:\n.IP \"\" 4\n.nf\nfrom apprise\\.decorators import notify\n\n# This example assumes you want your function to trigger on foobar://\n# references:\n@notify(on=\"foobar\", name=\"My Custom Notification\")\ndef my_wrapper(body, title, notify_type, *args, **kwargs):\n\n     print(\"Define your custom code here\")\n\n     # Returning True/False will relay your status back through Apprise\n     # Returning nothing (None by default) is always interpreted as True\n     return True\n.fi\n.IP \"\" 0\n.SH \"CONFIGURATION\"\nAn Apprise configuration file \\fIhttps://appriseit\\.com/getting\\-started/configuration/\\fR can be in the format of either \\fBTEXT\\fR or \\fBYAML\\fR where TEXT is the easiest and most ideal solution for most users\\. However YAML configuration files grants the user a bit more leverage and access to some of the internal features of Apprise\\. Regardless of which format you choose, both provide the users the ability to leverage \\fBtagging\\fR which adds a more rich and powerful notification environment\\.\n.P\nConfiguration files can be directly referenced via \\fBapprise\\fR when referencing the \\fB\\-\\-config=\\fR (\\fB\\-c\\fR) CLI directive\\. You can identify as many as you like on the command line and all of them will be loaded\\. You can also point your configuration to a cloud location (by referencing \\fBhttp://\\fR or \\fBhttps://\\fR\\. By default \\fBapprise\\fR looks in the following local locations for configuration files and loads them:\n.IP \"\" 4\n.nf\n~/\\.apprise\\.conf\n~/\\.apprise\\.yaml\n~/\\.config/apprise\\.conf\n~/\\.config/apprise\\.yaml\n\n~/\\.apprise/apprise\\.conf\n~/\\.apprise/apprise\\.yaml\n~/\\.config/apprise/apprise\\.conf\n~/\\.config/apprise/apprise\\.yaml\n\n/etc/apprise\\.conf\n/etc/apprise\\.yaml\n/etc/apprise/apprise\\.conf\n/etc/apprise/apprise\\.yaml\n.fi\n.IP \"\" 0\n.P\nThe \\fBconfiguration files\\fR specified above can also be identified with a \\fB\\.yml\\fR extension or even just entirely removing the \\fB\\.conf\\fR extension altogether\\.\n.P\nThe \\fBenvironment variable\\fR of \\fBAPPRISE_CONFIG_PATH\\fR can be specified to override the list identified above with one of your own\\. use a semi\\-colon (\\fB;\\fR), line\\-feed (\\fB\\en\\fR), and/or carriage return (\\fB\\er\\fR) to delimit multiple entries\\.\n.P\nIf a default configuration file is referenced in any way by the \\fBapprise\\fR tool, you no longer need to provide it a Service URL\\. Usage of the \\fBapprise\\fR tool simplifies to:\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-t \"my title\" \\-b \"my notification body\"\n.fi\n.IP \"\" 0\n.P\nIf you leveraged tagging \\fIhttps://appriseit\\.com/cli/usage/#tagging\\-and\\-filtering\\fR, you can define all of Apprise Service URLs in your configuration that you want and only specifically notify a subset of them:\n.IP \"\" 4\n.nf\n$ apprise \\-vv \\-\\-title \"Will Be Late Getting Home\" \\e\n    \\-\\-body \"Please go ahead and make dinner without me\\.\" \\e\n    \\-\\-tag=family\n.fi\n.IP \"\" 0\n.SH \"ENVIRONMENT VARIABLES\"\n\\fBAPPRISE_URLS\\fR: Specify the default URLs to notify IF none are otherwise specified on the command line explicitly\\. If the \\fB\\-\\-config\\fR (\\fB\\-c\\fR) is specified, then this will override any reference to this variable\\. Use white space and/or a comma (\\fB,\\fR) to delimit multiple entries\\.\n.P\n\\fBAPPRISE_CONFIG_PATH\\fR: Explicitly specify the config search path to use (overriding the default)\\. Use a semi\\-colon (\\fB;\\fR), line\\-feed (\\fB\\en\\fR), and/or carriage return (\\fB\\er\\fR) to delimit multiple entries\\.\n.P\n\\fBAPPRISE_PLUGIN_PATH\\fR: Explicitly specify the custom plugin search path to use (overriding the default)\\. Use a semi\\-colon (\\fB;\\fR), line\\-feed (\\fB\\en\\fR), and/or carriage return (\\fB\\er\\fR) to delimit multiple entries\\.\n.P\n\\fBAPPRISE_STORAGE_PATH\\fR: Explicitly specify the persistent storage path to use (overriding the default)\\.\n.SH \"BUGS\"\nIf you find any bugs, please make them known at: \\fIhttps://github\\.com/caronc/apprise/issues\\fR\n.SH \"DONATIONS\"\nIf you found Apprise useful at all, please consider sponsoring \\fIhttps://github\\.com/sponsors/caronc\\fR or donating \\fIhttps://www\\.paypal\\.com/donate/?hosted_button_id=CR6YF7KLQWQ5E\\fR!\n.SH \"COPYRIGHT\"\nApprise is Copyright (C) 2026 Chris Caron \\fIlead2gold@gmail\\.com\\fR\n"
  },
  {
    "path": "packaging/man/apprise.1.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n  <meta http-equiv='content-type' content='text/html;charset=utf-8'>\n  <meta name='generator' content='Ronn-NG/v0.10.1 (http://github.com/apjanke/ronn-ng/tree/0.10.1)'>\n  <title>apprise(1) - Push Notifications that work with just about every platform!</title>\n  <style type='text/css' media='all'>\n  /* style: man */\n  body#manpage {margin:0}\n  .mp {max-width:100ex;padding:0 9ex 1ex 4ex}\n  .mp p,.mp pre,.mp ul,.mp ol,.mp dl {margin:0 0 20px 0}\n  .mp h2 {margin:10px 0 0 0}\n  .mp > p,.mp > pre,.mp > ul,.mp > ol,.mp > dl {margin-left:8ex}\n  .mp h3 {margin:0 0 0 4ex}\n  .mp dt {margin:0;clear:left}\n  .mp dt.flush {float:left;width:8ex}\n  .mp dd {margin:0 0 0 9ex}\n  .mp h1,.mp h2,.mp h3,.mp h4 {clear:left}\n  .mp pre {margin-bottom:20px}\n  .mp pre+h2,.mp pre+h3 {margin-top:22px}\n  .mp h2+pre,.mp h3+pre {margin-top:5px}\n  .mp img {display:block;margin:auto}\n  .mp h1.man-title {display:none}\n  .mp,.mp code,.mp pre,.mp tt,.mp kbd,.mp samp,.mp h3,.mp h4 {font-family:monospace;font-size:14px;line-height:1.42857142857143}\n  .mp h2 {font-size:16px;line-height:1.25}\n  .mp h1 {font-size:20px;line-height:2}\n  .mp {text-align:justify;background:#fff}\n  .mp,.mp code,.mp pre,.mp pre code,.mp tt,.mp kbd,.mp samp {color:#131211}\n  .mp h1,.mp h2,.mp h3,.mp h4 {color:#030201}\n  .mp u {text-decoration:underline}\n  .mp code,.mp strong,.mp b {font-weight:bold;color:#131211}\n  .mp em,.mp var {font-style:italic;color:#232221;text-decoration:none}\n  .mp a,.mp a:link,.mp a:hover,.mp a code,.mp a pre,.mp a tt,.mp a kbd,.mp a samp {color:#0000ff}\n  .mp b.man-ref {font-weight:normal;color:#434241}\n  .mp pre {padding:0 4ex}\n  .mp pre code {font-weight:normal;color:#434241}\n  .mp h2+pre,h3+pre {padding-left:0}\n  ol.man-decor,ol.man-decor li {margin:3px 0 10px 0;padding:0;float:left;width:33%;list-style-type:none;text-transform:uppercase;color:#999;letter-spacing:1px}\n  ol.man-decor {width:100%}\n  ol.man-decor li.tl {text-align:left}\n  ol.man-decor li.tc {text-align:center;letter-spacing:4px}\n  ol.man-decor li.tr {text-align:right;float:right}\n  </style>\n</head>\n<!--\n  The following styles are deprecated and will be removed at some point:\n  div#man, div#man ol.man, div#man ol.head, div#man ol.man.\n\n  The .man-page, .man-decor, .man-head, .man-foot, .man-title, and\n  .man-navigation should be used instead.\n-->\n<body id='manpage'>\n  <div class='mp' id='man'>\n\n  <div class='man-navigation' style='display:none'>\n    <a href=\"#NAME\">NAME</a>\n    <a href=\"#SYNOPSIS\">SYNOPSIS</a>\n    <a href=\"#DESCRIPTION\">DESCRIPTION</a>\n    <a href=\"#OPTIONS\">OPTIONS</a>\n    <a href=\"#PERSISTENT-STORAGE\">PERSISTENT STORAGE</a>\n    <a href=\"#EXIT-STATUS\">EXIT STATUS</a>\n    <a href=\"#SERVICE-URLS\">SERVICE URLS</a>\n    <a href=\"#EXAMPLES\">EXAMPLES</a>\n    <a href=\"#CUSTOM-PLUGIN-NOTIFICATIONS\">CUSTOM PLUGIN/NOTIFICATIONS</a>\n    <a href=\"#CONFIGURATION\">CONFIGURATION</a>\n    <a href=\"#ENVIRONMENT-VARIABLES\">ENVIRONMENT VARIABLES</a>\n    <a href=\"#BUGS\">BUGS</a>\n    <a href=\"#DONATIONS\">DONATIONS</a>\n    <a href=\"#COPYRIGHT\">COPYRIGHT</a>\n  </div>\n\n  <ol class='man-decor man-head man head'>\n    <li class='tl'>apprise(1)</li>\n    <li class='tc'></li>\n    <li class='tr'>apprise(1)</li>\n  </ol>\n\n  \n\n<h2 id=\"NAME\">NAME</h2>\n<p class=\"man-name\">\n  <code>apprise</code> - <span class=\"man-whatis\">Push Notifications that work with just about every platform!</span>\n</p>\n<h2 id=\"SYNOPSIS\">SYNOPSIS</h2>\n\n<p><code>apprise</code> [<var>options</var>...] <var>service-url</var>...<br>\n<code>apprise</code> storage [<var>options</var>...] [<var>action</var>] <var>url-id</var>...<br></p>\n\n<h2 id=\"DESCRIPTION\">DESCRIPTION</h2>\n\n<p><strong>Apprise</strong> allows you to send a notification to <em>almost all</em> of the most\npopular notification services available to us today such as: Discord,\nTelegram, Pushbullet, Slack, Twitter, etc.</p>\n\n<ul>\n  <li>One notification library to rule them all.</li>\n  <li>A common and intuitive notification syntax.</li>\n  <li>Supports the handling of images (to the notification services that will\naccept them).</li>\n  <li>It's incredibly lightweight.</li>\n  <li>Amazing response times because all messages sent asynchronously.</li>\n</ul>\n\n<h2 id=\"OPTIONS\">OPTIONS</h2>\n\n<p>The Apprise options are as follows:</p>\n\n<p><code>-b</code>, <code>--body=</code><var>VALUE</var>:\n  Specify the message body. If no body is specified then content is read from\n  <var>stdin</var>.</p>\n\n<p><code>-t</code>, <code>--title=</code><var>VALUE</var>:\n  Specify the message title. This field is completely optional.</p>\n\n<p><code>-c</code>, <code>--config=</code><var>CONFIG-URL</var>:\n  Specify one or more configuration locations.</p>\n\n<p><code>-a</code>, <code>--attach=</code><var>ATTACH-URL</var>:\n  Specify one or more file attachment locations.</p>\n\n<p><code>-P</code>, <code>--plugin-path=</code><var>PATH</var>:\n  Specify a path to scan for custom notification plugin support.\n  You can create your own notification by simply creating a Python file\n  that contains the <code>@notify(\"schema\")</code> decorator.</p>\n\n<p>You can optionally choose to specify more than one <strong>--plugin-path</strong> (<strong>-P</strong>)\n  to increase the modules included.</p>\n\n<p><code>-n</code>, <code>--notification-type=</code><var>VALUE</var>:\n  Specify the message type (default=info). Possible values are \"info\",\n  \"success\", \"failure\", and \"warning\".</p>\n\n<p><code>-i</code>, <code>--input-format=</code><var>VALUE</var>:\n  Specify the input message format (default=text). Possible values are \"text\",\n  \"html\", and \"markdown\".</p>\n\n<p><code>-T</code>, <code>--theme=</code><var>VALUE</var>:\n  Specify the default theme.</p>\n\n<p><code>-g</code>, <code>--tag=</code><var>VALUE</var>:\n  Specify one or more tags to filter which services to notify:</p>\n\n<ul>\n  <li>\n<code>-g \"tagA\" -g \"tagB\"</code>: Match tagA <strong>OR</strong> tagB (Union).</li>\n  <li>\n<code>-g \"tagA,tagB\"</code>: Match tagA <strong>AND</strong> tagB (Strict).</li>\n  <li>\n<code>-g \"all\"</code>: Notify <strong>ALL</strong> services.</li>\n  <li>\n<code>(Omitted)</code>: Notify <strong>untagged</strong> services only.</li>\n</ul>\n\n<p><code>-Da</code>, <code>--disable-async</code>:\n  Send notifications synchronously (one after the other) instead of\n  all at once.</p>\n\n<p><code>-R</code>, <code>--recursion-depth</code><var>INTEGER</var>:\n  The number of recursive import entries that can be loaded from within\n  Apprise configuration. By default this is set to 1. If this is set to\n  zero, then import statements found in any configuration is ignored.</p>\n\n<p><code>-e</code>, <code>--interpret-escapes</code>\n  Enable interpretation of backslash escapes. For example, this would convert\n  sequences such as \\n and \\r to their respected ascii new-line and carriage\n  return characters prior to the delivery of the notification.</p>\n\n<p><code>-j</code>, <code>--interpret-emojis</code>\n  Enable interpretation of emoji strings. For example, this would convert\n  sequences such as :smile: or :grin: to their respected unicode emoji\n  character.</p>\n\n<p><code>-S</code>, <code>--storage-path=</code><var>PATH</var>:\n  Specify the path to the persistent storage caching location</p>\n\n<p><code>-SM</code>, <code>--storage-mode=</code><var>MODE</var>:\n  Specify the persistent storage operational mode. Possible values are \"auto\",\n  \"flush\", and \"memory\". The default is \"auto\" if not specified.</p>\n\n<p><code>-SPD</code>, <code>--storage-prune-days=</code><var>INTEGER</var>:\n  Define the number of days the storage prune should run using.\n  Setting this to zero (0) will eliminate all accumulated content. By\n  default this value is 30 (days).</p>\n\n<p><code>-SUL</code>, <code>--storage-uid-length=</code><var>INTEGER</var>:\n  Define the number of unique characters to store persistent cache in.\n  By default this value is 8 (characters).</p>\n\n<p><code>-d</code>, <code>--dry-run</code>:\n  Perform a trial run but only prints the notification services to-be\n  triggered to <strong>stdout</strong>. Notifications are never sent using this mode.</p>\n\n<p><code>-l</code>, <code>--details</code>\n  Prints details about the current services supported by Apprise.</p>\n\n<p><code>-v</code>, <code>--verbose</code>:\n  The more of these you specify, the more verbose the output is. e.g: -vvvv</p>\n\n<p><code>-D</code>, <code>--debug</code>:\n  A debug mode; useful for troubleshooting.</p>\n\n<p><code>-V</code>, <code>--version</code>:\n  Display the apprise version and exit.</p>\n\n<p><code>-h</code>, <code>--help</code>:\n  Show this message and exit.</p>\n\n<h2 id=\"PERSISTENT-STORAGE\">PERSISTENT STORAGE</h2>\n\n<p>Persistent storage by default writes to the following location unless the environment variable <code>APPRISE_STORAGE_PATH</code> overrides it and/or <code>--storage-path</code> (<code>-SP</code>) is specified to override it:</p>\n\n<pre><code>~/.local/share/apprise/cache\n</code></pre>\n\n<p>To utilize the <a href=\"https://appriseit.com/cli/persistent-storage/\">persistent storage</a> element associated with Apprise, simply\nspecify the keyword <strong>storage</strong></p>\n\n<pre><code>$ apprise storage\n</code></pre>\n\n<p>The <strong>storage</strong> action has the following sub actions:</p>\n\n<p><code>list</code>:\n  List all of the detected persistent storage elements and their state\n  (<strong>stale</strong>, <strong>active</strong>, or <strong>unused</strong>).  This is the default action if\n  nothing further is identified.</p>\n\n<p><code>prune</code>:\n  Removes all persistent storage that has not been referenced for more than 30\n  days. You can optionally set the <code>--storage-prune-days</code> to alter this\n  default value.</p>\n\n<p><code>clean</code>:\n  Removes all persistent storage regardless of age.</p>\n\n<h2 id=\"EXIT-STATUS\">EXIT STATUS</h2>\n\n<p><strong>apprise</strong> exits with a status of:</p>\n\n<ul>\n  <li>\n<strong>0</strong> if all of the notifications were sent successfully.</li>\n  <li>\n<strong>1</strong> if one or more notifications could not be sent.</li>\n  <li>\n<strong>2</strong> if there was an error specified on the command line such as not\nproviding an valid argument.</li>\n  <li>\n<strong>3</strong> if there was one or more Apprise Service URLs successfully\nloaded but none could be notified due to user filtering (via tags).</li>\n</ul>\n\n<h2 id=\"SERVICE-URLS\">SERVICE URLS</h2>\n\n<p>There are too many service URL and combinations to list here. It's best to\nvisit the <a href=\"https://appriseit.com/services/\">Apprise GitHub page</a> and see what's available.\nAlso try out the <a href=\"https://appriseit.com/tools/url-builder/\">Apprise URL Builder</a> to easily construct the URLS\nthis tool supports and works with.</p>\n\n<p>The <strong>environment variable</strong> of <code>APPRISE_URLS</code> (comma/space delimited) can be specified to\nprovide the default set of URLs you wish to notify if none are otherwise specified.</p>\n\n<h2 id=\"EXAMPLES\">EXAMPLES</h2>\n\n<p>Send a notification to as many servers as you want to specify as you can\neasily chain them together:</p>\n\n<pre><code>$ apprise -vv -t \"my title\" -b \"my notification body\" \\\n   \"mailto://myemail:mypass@gmail.com\" \\\n   \"pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b\"\n</code></pre>\n\n<p>If you don't specify a <strong>--body</strong> (<strong>-b</strong>) then stdin is used allowing you to\nuse the tool as part of your every day administration:</p>\n\n<pre><code>$ cat /proc/cpuinfo | apprise -vv -t \"cpu info\" \\\n    \"mailto://myemail:mypass@gmail.com\"\n</code></pre>\n\n<p>Load in a configuration file which identifies all of your notification service\nURLs and notify them all:</p>\n\n<pre><code>$ apprise -vv -t \"my title\" -b \"my notification body\" \\\n   --config=~/apprise.yml\n</code></pre>\n\n<p>Load in a configuration file from a remote server that identifies all of your\nnotification service URLs and only notify the ones tagged as <em>devops</em>.</p>\n\n<pre><code>$ apprise -vv -t \"my title\" -b \"my notification body\" \\\n   --config=https://localhost/my/apprise/config \\\n   -t devops\n</code></pre>\n\n<p><strong>Tagging Logic Examples:</strong></p>\n\n<p>Notify any service tagged with \"devops\" OR \"admin\" (Union):</p>\n\n<pre><code>$ apprise -vv -t \"Union Test\" \\\n   --config=~/apprise.yml \\\n   -g devops -g admin\n</code></pre>\n\n<p>Notify only services tagged with BOTH \"devops\" AND \"critical\" (Intersection):</p>\n\n<pre><code>$ apprise -vv -t \"Intersection Test\" \\\n   --config=~/apprise.yml \\\n   -g devops,critical\n</code></pre>\n\n<p>Include an attachment:</p>\n\n<pre><code>$ apprise -vv -t \"School Assignment\" -b \"See attached\" \\\n   --attach=Documents/FinalReport.docx\n</code></pre>\n\n<p>List all of the notifications loaded:</p>\n\n<pre><code>$ apprise --dry-run --tag=all\n</code></pre>\n\n<p>List all of the details around the current persistent storage setup:</p>\n\n<pre><code>$ apprise storage list\n</code></pre>\n\n<p>Prune all persistent storage that has not been referenced for at least 10 days or more</p>\n\n<pre><code>$ apprise storage prune --storage-prune-days=10\n</code></pre>\n\n<h2 id=\"CUSTOM-PLUGIN-NOTIFICATIONS\">CUSTOM PLUGIN/NOTIFICATIONS</h2>\n<p>Apprise can additionally allow you to define your own custom <strong>schema://</strong>\nentries that you can trigger on and call services you've defined.</p>\n\n<p>By default <strong>apprise</strong> looks in the following local locations for custom plugin\nfiles and loads them:</p>\n\n<pre><code>~/.apprise/plugins\n~/.config/apprise/plugins\n/var/lib/apprise/plugins\n</code></pre>\n\n<p>The <strong>environment variable</strong> of <code>APPRISE_PLUGIN_PATH</code> can be specified to override\nthe list identified above with one of your own.  use a semi-colon (<code>;</code>), line-feed (<code>\\n</code>),\nand/or carriage return (<code>\\r</code>) to delimit multiple entries.</p>\n\n<p>Simply create your own python file with the following bare minimum content in\nit:</p>\n\n<pre><code>from apprise.decorators import notify\n\n# This example assumes you want your function to trigger on foobar://\n# references:\n@notify(on=\"foobar\", name=\"My Custom Notification\")\ndef my_wrapper(body, title, notify_type, *args, **kwargs):\n\n     print(\"Define your custom code here\")\n\n     # Returning True/False will relay your status back through Apprise\n     # Returning nothing (None by default) is always interpreted as True\n     return True\n</code></pre>\n\n<h2 id=\"CONFIGURATION\">CONFIGURATION</h2>\n\n<p>An <a href=\"https://appriseit.com/getting-started/configuration/\">Apprise configuration file</a> can be in the format of either <strong>TEXT</strong>\nor <strong>YAML</strong> where TEXT is the easiest and most ideal solution for most users.\nHowever YAML configuration files grants the user a bit more leverage and access\nto some of the internal features of Apprise. Regardless of which format you choose,\nboth provide the users the ability to leverage <strong>tagging</strong> which adds a more rich and\npowerful notification environment.</p>\n\n<p>Configuration files can be directly referenced via <strong>apprise</strong> when referencing\nthe <code>--config=</code> (<code>-c</code>) CLI directive.  You can identify as many as you like on the\ncommand line and all of them will be loaded.  You can also point your configuration to\na cloud location (by referencing <code>http://</code> or <code>https://</code>. By default <strong>apprise</strong> looks\nin the following local locations for configuration files and loads them:</p>\n\n<pre><code>~/.apprise.conf\n~/.apprise.yaml\n~/.config/apprise.conf\n~/.config/apprise.yaml\n\n~/.apprise/apprise.conf\n~/.apprise/apprise.yaml\n~/.config/apprise/apprise.conf\n~/.config/apprise/apprise.yaml\n\n/etc/apprise.conf\n/etc/apprise.yaml\n/etc/apprise/apprise.conf\n/etc/apprise/apprise.yaml\n</code></pre>\n\n<p>The <strong>configuration files</strong> specified above can also be identified with a <code>.yml</code>\nextension or even just entirely removing the <code>.conf</code> extension altogether.</p>\n\n<p>The <strong>environment variable</strong> of <code>APPRISE_CONFIG_PATH</code> can be specified to override\nthe list identified above with one of your own.  use a semi-colon (<code>;</code>), line-feed (<code>\\n</code>),\nand/or carriage return (<code>\\r</code>) to delimit multiple entries.</p>\n\n<p>If a default configuration file is referenced in any way by the <strong>apprise</strong>\ntool, you no longer need to provide it a Service URL.  Usage of the <strong>apprise</strong>\ntool simplifies to:</p>\n\n<pre><code>$ apprise -vv -t \"my title\" -b \"my notification body\"\n</code></pre>\n\n<p>If you leveraged <a href=\"https://appriseit.com/cli/usage/#tagging-and-filtering\">tagging</a>, you can define all of Apprise Service URLs in your\nconfiguration that you want and only specifically notify a subset of them:</p>\n\n<pre><code>$ apprise -vv --title \"Will Be Late Getting Home\" \\\n    --body \"Please go ahead and make dinner without me.\" \\\n    --tag=family\n</code></pre>\n\n<h2 id=\"ENVIRONMENT-VARIABLES\">ENVIRONMENT VARIABLES</h2>\n<p><code>APPRISE_URLS</code>:\n  Specify the default URLs to notify IF none are otherwise specified on the command line\n  explicitly.  If the <code>--config</code> (<code>-c</code>) is specified, then this will override any\n  reference to this variable. Use white space and/or a comma (<code>,</code>) to delimit multiple entries.</p>\n\n<p><code>APPRISE_CONFIG_PATH</code>:\n  Explicitly specify the config search path to use (overriding the default).\n  Use a semi-colon (<code>;</code>), line-feed (<code>\\n</code>), and/or carriage return (<code>\\r</code>) to delimit multiple entries.</p>\n\n<p><code>APPRISE_PLUGIN_PATH</code>:\n  Explicitly specify the custom plugin search path to use (overriding the default).\n  Use a semi-colon (<code>;</code>), line-feed (<code>\\n</code>), and/or carriage return (<code>\\r</code>) to delimit multiple entries.</p>\n\n<p><code>APPRISE_STORAGE_PATH</code>:\n  Explicitly specify the persistent storage path to use (overriding the default).</p>\n\n<h2 id=\"BUGS\">BUGS</h2>\n\n<p>If you find any bugs, please make them known at:\n<a href=\"https://github.com/caronc/apprise/issues\" data-bare-link=\"true\">https://github.com/caronc/apprise/issues</a></p>\n\n<h2 id=\"DONATIONS\">DONATIONS</h2>\n<p>If you found Apprise useful at all, <a href=\"https://github.com/sponsors/caronc\">please consider sponsoring</a> or <a href=\"https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E\">donating</a>!</p>\n\n<h2 id=\"COPYRIGHT\">COPYRIGHT</h2>\n\n<p>Apprise is Copyright (C) 2026 Chris Caron <a href=\"mailto:lead2gold@gmail.com\" data-bare-link=\"true\">lead2gold@gmail.com</a></p>\n\n  <ol class='man-decor man-foot man foot'>\n    <li class='tl'>Chris Caron &lt;lead2gold@gmail.com&gt;</li>\n    <li class='tc'>March 2026</li>\n    <li class='tr'>apprise(1)</li>\n  </ol>\n\n  </div>\n</body>\n</html>\n"
  },
  {
    "path": "packaging/man/apprise.md",
    "content": "apprise(1) -- Push Notifications that work with just about every platform!\n==========================================================================\n\n## SYNOPSIS\n\n`apprise` [<options>...] <service-url>...<br>\n`apprise` storage [<options>...] [<action>] <url-id>...<br>\n\n## DESCRIPTION\n\n**Apprise** allows you to send a notification to _almost all_ of the most\npopular notification services available to us today such as: Discord,\nTelegram, Pushbullet, Slack, Twitter, etc.\n\n  * One notification library to rule them all.\n  * A common and intuitive notification syntax.\n  * Supports the handling of images (to the notification services that will\n    accept them).\n  * It's incredibly lightweight.\n  * Amazing response times because all messages sent asynchronously.\n\n## OPTIONS\n\nThe Apprise options are as follows:\n\n  `-b`, `--body=`<VALUE>:\n  Specify the message body. If no body is specified then content is read from\n  <stdin>.\n\n  `-t`, `--title=`<VALUE>:\n  Specify the message title. This field is completely optional.\n\n  `-c`, `--config=`<CONFIG-URL>:\n  Specify one or more configuration locations.\n\n  `-a`, `--attach=`<ATTACH-URL>:\n  Specify one or more file attachment locations.\n\n  `-P`, `--plugin-path=`<PATH>:\n  Specify a path to scan for custom notification plugin support.\n  You can create your own notification by simply creating a Python file\n  that contains the `@notify(\"schema\")` decorator.\n\n  You can optionally choose to specify more than one **--plugin-path** (**-P**)\n  to increase the modules included.\n\n  `-n`, `--notification-type=`<VALUE>:\n  Specify the message type (default=info). Possible values are \"info\",\n  \"success\", \"failure\", and \"warning\".\n\n  `-i`, `--input-format=`<VALUE>:\n  Specify the input message format (default=text). Possible values are \"text\",\n  \"html\", and \"markdown\".\n\n  `-T`, `--theme=`<VALUE>:\n  Specify the default theme.\n\n  `-g`, `--tag=`<VALUE>:\n  Specify one or more tags to filter which services to notify:\n\n  * `-g \"tagA\" -g \"tagB\"`: Match tagA **OR** tagB (Union).\n  * `-g \"tagA,tagB\"`: Match tagA **AND** tagB (Strict).\n  * `-g \"all\"`: Notify **ALL** services.\n  * `(Omitted)`: Notify **untagged** services only.\n\n  `-Da`, `--disable-async`:\n  Send notifications synchronously (one after the other) instead of\n  all at once.\n\n  `-R`, `--recursion-depth`<INTEGER>:\n  The number of recursive import entries that can be loaded from within\n  Apprise configuration. By default this is set to 1. If this is set to\n  zero, then import statements found in any configuration is ignored.\n\n  `-e`, `--interpret-escapes`\n  Enable interpretation of backslash escapes. For example, this would convert\n  sequences such as \\n and \\r to their respected ascii new-line and carriage\n  return characters prior to the delivery of the notification.\n\n  `-j`, `--interpret-emojis`\n  Enable interpretation of emoji strings. For example, this would convert\n  sequences such as :smile: or :grin: to their respected unicode emoji\n  character.\n\n  `-S`, `--storage-path=`<PATH>:\n  Specify the path to the persistent storage caching location\n\n  `-SM`, `--storage-mode=`<MODE>:\n  Specify the persistent storage operational mode. Possible values are \"auto\",\n  \"flush\", and \"memory\". The default is \"auto\" if not specified.\n\n  `-SPD`, `--storage-prune-days=`<INTEGER>:\n  Define the number of days the storage prune should run using.\n  Setting this to zero (0) will eliminate all accumulated content. By\n  default this value is 30 (days).\n\n  `-SUL`, `--storage-uid-length=`<INTEGER>:\n  Define the number of unique characters to store persistent cache in.\n  By default this value is 8 (characters).\n\n  `-d`, `--dry-run`:\n  Perform a trial run but only prints the notification services to-be\n  triggered to **stdout**. Notifications are never sent using this mode.\n\n  `-l`, `--details`\n  Prints details about the current services supported by Apprise.\n\n  `-v`, `--verbose`:\n  The more of these you specify, the more verbose the output is. e.g: -vvvv\n\n  `-D`, `--debug`:\n  A debug mode; useful for troubleshooting.\n\n  `-V`, `--version`:\n  Display the apprise version and exit.\n\n  `-h`, `--help`:\n  Show this message and exit.\n\n## PERSISTENT STORAGE\n\nPersistent storage by default writes to the following location unless the environment variable `APPRISE_STORAGE_PATH` overrides it and/or `--storage-path` (`-SP`) is specified to override it:\n\n    ~/.local/share/apprise/cache\n\nTo utilize the [persistent storage][pstorage] element associated with Apprise, simply\nspecify the keyword **storage**\n\n    $ apprise storage\n\nThe **storage** action has the following sub actions:\n\n  `list`:\n  List all of the detected persistent storage elements and their state\n  (**stale**, **active**, or **unused**).  This is the default action if\n  nothing further is identified.\n\n  `prune`:\n  Removes all persistent storage that has not been referenced for more than 30\n  days. You can optionally set the `--storage-prune-days` to alter this\n  default value.\n\n  `clean`:\n  Removes all persistent storage regardless of age.\n\n## EXIT STATUS\n\n**apprise** exits with a status of:\n\n* **0** if all of the notifications were sent successfully.\n* **1** if one or more notifications could not be sent.\n* **2** if there was an error specified on the command line such as not\n  providing an valid argument.\n* **3** if there was one or more Apprise Service URLs successfully\n  loaded but none could be notified due to user filtering (via tags).\n\n## SERVICE URLS\n\nThere are too many service URL and combinations to list here. It's best to\nvisit the [Apprise GitHub page][serviceurls] and see what's available.\nAlso try out the [Apprise URL Builder][buildurls] to easily construct the URLS\nthis tool supports and works with.\n\n[serviceurls]: https://appriseit.com/services/\n[buildurls]: https://appriseit.com/tools/url-builder/\n\nThe **environment variable** of `APPRISE_URLS` (comma/space delimited) can be specified to\nprovide the default set of URLs you wish to notify if none are otherwise specified.\n\n## EXAMPLES\n\nSend a notification to as many servers as you want to specify as you can\neasily chain them together:\n\n    $ apprise -vv -t \"my title\" -b \"my notification body\" \\\n       \"mailto://myemail:mypass@gmail.com\" \\\n       \"pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b\"\n\nIf you don't specify a **--body** (**-b**) then stdin is used allowing you to\nuse the tool as part of your every day administration:\n\n    $ cat /proc/cpuinfo | apprise -vv -t \"cpu info\" \\\n        \"mailto://myemail:mypass@gmail.com\"\n\nLoad in a configuration file which identifies all of your notification service\nURLs and notify them all:\n\n    $ apprise -vv -t \"my title\" -b \"my notification body\" \\\n       --config=~/apprise.yml\n\nLoad in a configuration file from a remote server that identifies all of your\nnotification service URLs and only notify the ones tagged as _devops_.\n\n    $ apprise -vv -t \"my title\" -b \"my notification body\" \\\n       --config=https://localhost/my/apprise/config \\\n       -t devops\n\n**Tagging Logic Examples:**\n\nNotify any service tagged with \"devops\" OR \"admin\" (Union):\n\n    $ apprise -vv -t \"Union Test\" \\\n       --config=~/apprise.yml \\\n       -g devops -g admin\n\nNotify only services tagged with BOTH \"devops\" AND \"critical\" (Intersection):\n\n    $ apprise -vv -t \"Intersection Test\" \\\n       --config=~/apprise.yml \\\n       -g devops,critical\n\nInclude an attachment:\n\n    $ apprise -vv -t \"School Assignment\" -b \"See attached\" \\\n       --attach=Documents/FinalReport.docx\n\nList all of the notifications loaded:\n\n    $ apprise --dry-run --tag=all\n\nList all of the details around the current persistent storage setup:\n\n    $ apprise storage list\n\nPrune all persistent storage that has not been referenced for at least 10 days or more\n\n    $ apprise storage prune --storage-prune-days=10\n\n## CUSTOM PLUGIN/NOTIFICATIONS\nApprise can additionally allow you to define your own custom **schema://**\nentries that you can trigger on and call services you've defined.\n\nBy default **apprise** looks in the following local locations for custom plugin\nfiles and loads them:\n\n    ~/.apprise/plugins\n    ~/.config/apprise/plugins\n    /var/lib/apprise/plugins\n\nThe **environment variable** of `APPRISE_PLUGIN_PATH` can be specified to override\nthe list identified above with one of your own.  use a semi-colon (`;`), line-feed (`\\n`),\nand/or carriage return (`\\r`) to delimit multiple entries.\n\nSimply create your own python file with the following bare minimum content in\nit:\n\n    from apprise.decorators import notify\n\n    # This example assumes you want your function to trigger on foobar://\n    # references:\n    @notify(on=\"foobar\", name=\"My Custom Notification\")\n    def my_wrapper(body, title, notify_type, *args, **kwargs):\n\n         print(\"Define your custom code here\")\n\n         # Returning True/False will relay your status back through Apprise\n         # Returning nothing (None by default) is always interpreted as True\n         return True\n\n## CONFIGURATION\n\nAn [Apprise configuration file][config] can be in the format of either **TEXT**\nor **YAML** where TEXT is the easiest and most ideal solution for most users.\nHowever YAML configuration files grants the user a bit more leverage and access\nto some of the internal features of Apprise. Regardless of which format you choose,\nboth provide the users the ability to leverage **tagging** which adds a more rich and\npowerful notification environment.\n\nConfiguration files can be directly referenced via **apprise** when referencing\nthe `--config=` (`-c`) CLI directive.  You can identify as many as you like on the\ncommand line and all of them will be loaded.  You can also point your configuration to\na cloud location (by referencing `http://` or `https://`. By default **apprise** looks\nin the following local locations for configuration files and loads them:\n\n    ~/.apprise.conf\n    ~/.apprise.yaml\n    ~/.config/apprise.conf\n    ~/.config/apprise.yaml\n\n    ~/.apprise/apprise.conf\n    ~/.apprise/apprise.yaml\n    ~/.config/apprise/apprise.conf\n    ~/.config/apprise/apprise.yaml\n\n    /etc/apprise.conf\n    /etc/apprise.yaml\n    /etc/apprise/apprise.conf\n    /etc/apprise/apprise.yaml\n\nThe **configuration files** specified above can also be identified with a `.yml`\nextension or even just entirely removing the `.conf` extension altogether.\n\nThe **environment variable** of `APPRISE_CONFIG_PATH` can be specified to override\nthe list identified above with one of your own.  use a semi-colon (`;`), line-feed (`\\n`),\nand/or carriage return (`\\r`) to delimit multiple entries.\n\nIf a default configuration file is referenced in any way by the **apprise**\ntool, you no longer need to provide it a Service URL.  Usage of the **apprise**\ntool simplifies to:\n\n    $ apprise -vv -t \"my title\" -b \"my notification body\"\n\nIf you leveraged [tagging][tagging], you can define all of Apprise Service URLs in your\nconfiguration that you want and only specifically notify a subset of them:\n\n    $ apprise -vv --title \"Will Be Late Getting Home\" \\\n        --body \"Please go ahead and make dinner without me.\" \\\n        --tag=family\n\n[config]: https://appriseit.com/getting-started/configuration/\n[tagging]: https://appriseit.com/cli/usage/#tagging-and-filtering\n[pstorage]: https://appriseit.com/cli/persistent-storage/\n\n## ENVIRONMENT VARIABLES\n  `APPRISE_URLS`:\n  Specify the default URLs to notify IF none are otherwise specified on the command line\n  explicitly.  If the `--config` (`-c`) is specified, then this will override any\n  reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries.\n\n  `APPRISE_CONFIG_PATH`:\n  Explicitly specify the config search path to use (overriding the default).\n  Use a semi-colon (`;`), line-feed (`\\n`), and/or carriage return (`\\r`) to delimit multiple entries.\n\n  `APPRISE_PLUGIN_PATH`:\n  Explicitly specify the custom plugin search path to use (overriding the default).\n  Use a semi-colon (`;`), line-feed (`\\n`), and/or carriage return (`\\r`) to delimit multiple entries.\n\n  `APPRISE_STORAGE_PATH`:\n  Explicitly specify the persistent storage path to use (overriding the default).\n\n## BUGS\n\nIf you find any bugs, please make them known at:\n<https://github.com/caronc/apprise/issues>\n\n## DONATIONS\nIf you found Apprise useful at all, [please consider sponsoring][sponsorship] or [donating][donating]!\n\n[sponsorship]: https://github.com/sponsors/caronc\n[donating]: https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E\n\n## COPYRIGHT\n\nApprise is Copyright (C) 2026 Chris Caron <lead2gold@gmail.com>\n"
  },
  {
    "path": "packaging/redhat/python-apprise.rpmlintrc.el10",
    "content": "from Config import *\n\n# Ignore false-positive spelling errors on brand names\naddFilter('spelling-error.*ntfy')\n"
  },
  {
    "path": "packaging/redhat/python-apprise.rpmlintrc.el9",
    "content": "from Config import *\n\n# Ignore false-positive spelling errors on brand names\naddFilter('spelling-error.*ntfy')\naddFilter('spelling-error.*httpSMS')\naddFilter('invalid-license BSD-2-Clause')\naddFilter('obsolete-not-provided python.*-apprise')\n"
  },
  {
    "path": "packaging/redhat/python-apprise.spec",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n###################################################################\n%if 0%{?_module_build}\n%bcond_with tests\n%else\n# When bootstrapping Python, we cannot test this yet\n%bcond_without tests\n%endif\n\n# Handling of new python building structure (for backwards compatiblity)\n%global legacy_python_build 0\n%if 0%{?fedora} && 0%{?fedora} <= 29\n%global legacy_python_build 1\n%endif\n%if 0%{?rhel} && 0%{?rhel} <= 9\n%global legacy_python_build 1\n%endif\n\n%global pypi_name apprise\n\n# Handle rpmlint false positives\n# - Prevent warnings:\n#    en_US ntfy -> notify\n#    en_US httpSMS -> HTTP\n#\n# rpmlint: ignore-spelling httpSMS ntfy\n\n# - RHEL9 does not recognize: BSD-2-Clause which is correct\n#\n# rpmlint: ignore invalid-license\n\n%global common_description %{expand: \\\nApprise is a Python package that simplifies access to many popular \\\nnotification services. It supports sending alerts to platforms such as: \\\n\\\n`46elks`, `AfricasTalking`, `Apprise API`, `APRS`, `AWS SES`, `AWS SNS`, \\\n`Bark`, `BlueSky`, `Brevo`, `Burst SMS`, `BulkSMS`, `BulkVS`, `Chanify`, \\\n`Clickatell`, `ClickSend`, `DAPNET`, `DingTalk`, `Discord`, \\\n`Dot. (Quote/0)`, `E-Mail`, `Emby`, `FCM`, `Feishu`, `Flock`, `Fluxer`, \\\n`Free Mobile`, `Google Chat`, `Gotify`, `Growl`, `Guilded`, \\\n`Home Assistant`, `httpSMS`, `IFTTT`, `IRC`, `Jellyfin`, `Join`, `Kavenegar`, \\\n`KODI`, `Kumulos`, `LaMetric`, `Lark`, `Line`, `MacOSX`, `Mailgun`, \\\n`Mastodon`, `Mattermost`, `Matrix`, `MessageBird`, `Microsoft Windows`, \\\n`Microsoft Teams`, `Misskey`, `MQTT`, `MSG91`, `MyAndroid`, `Nexmo`, \\\n`Nextcloud`, `NextcloudTalk`, `Notica`, `NotificationAPI`, `Notifiarr`, \\\n`Notifico`, `ntfy`, `Office365`, `OneSignal`, `Opsgenie`, `PagerDuty`, \\\n`PagerTree`, `ParsePlatform`, `Plivo`, `PopcornNotify`, `Prowl`, `Pushalot`, \\\n`PushBullet`, `Pushjet`, `PushMe`, `Pushover`, `Pushplus`, `PushSafer`, \\\n`Pushy`, `PushDeer`, `QQ Push`, `Revolt`, `Reddit`, `Resend`, \\\n`Rocket.Chat`, `RSyslog`, `SendGrid`, `SendPulse`, `ServerChan`, `Seven`, \\\n`SFR`, `Signal`, `SIGNL4`, `SimplePush`, `Sinch`, `Slack`, `SMPP`, \\\n`SMSEagle`, `SMS Manager`, `SMTP2Go`, `SparkPost`, `Splunk`, `Spike`, \\\n`Spug Push`, `Super Toasty`, `Streamlabs`, `Stride`, `Synology Chat`, \\\n`Syslog`, `Techulus Push`, `Telegram`, `Threema Gateway`, `Twilio`, \\\n`Twitter`, `Twist`, `Vapid`, `Viber`, `VictorOps`, `Voipms`, `Vonage`, \\\n`WebPush`, `WeCom Bot`, `WhatsApp`, `Webex Teams`, `Workflows`, `WxPusher`,\n`XBMC`, `XMPP`, and `Zulip`.}\n\nName:           python-%{pypi_name}\nVersion:        1.9.8\nRelease:        1%{?dist}\nSummary:        A simple wrapper to many popular notification services used today\nLicense:        BSD-2-Clause\nURL:            https://github.com/caronc/%{pypi_name}\nSource0:        %{url}/archive/v%{version}/%{pypi_name}-%{version}.tar.gz\nBuildArch:      noarch\n\n%description %{common_description}\n\n%package -n %{pypi_name}\nSummary: Notify messaging platforms from the command line\n\nObsoletes: %{pypi_name} < %{version}-%{release}\nProvides: %{pypi_name} = %{version}-%{release}\n\nRequires: python3dist(click) >= 5.0\nRequires: python%{python3_pkgversion}-%{pypi_name} = %{version}-%{release}\n\n%description -n %{pypi_name}\nAn accompanied CLI tool that can be used as part of Apprise\nto issue notifications from the command line to you favorite\nservices.\n\n%package -n python%{python3_pkgversion}-%{pypi_name}\nSummary: A simple wrapper to many popular notification services used today\n\nObsoletes: python%{python3_pkgversion}-%{pypi_name} < %{version}-%{release}\n%{?python_provide:%python_provide python%{python3_pkgversion}-%{pypi_name}}\n\nBuildRequires: gettext\nBuildRequires: python%{python3_pkgversion}-devel\n\n%if %{legacy_python_build}\nBuildRequires: python3dist(setuptools)\n%endif\n\nBuildRequires: python3dist(wheel)\nBuildRequires: python3dist(requests)\nBuildRequires: python3dist(requests-oauthlib)\nBuildRequires: python3dist(click) >= 5.0\nBuildRequires: python3dist(markdown)\nBuildRequires: python3dist(pyyaml)\nBuildRequires: python3dist(babel)\nBuildRequires: python3dist(cryptography)\nBuildRequires: python3dist(certifi)\nBuildRequires: python3dist(tox)\n\n%if %{with tests}\nBuildRequires: python3dist(pytest)\nBuildRequires: python3dist(pytest-mock)\n%endif\n\nRequires: python3dist(requests)\nRequires: python3dist(requests-oauthlib)\nRequires: python3dist(markdown)\nRequires: python3dist(cryptography)\nRequires: python3dist(certifi)\nRequires: python3dist(pyyaml)\n\nRecommends: python3dist(paho-mqtt)\nRecommends: python3dist(slixmpp)\n\n%if 0%{?legacy_python_build} == 0\n# Logic for non-RHEL ≤ 9 systems\n%generate_buildrequires\n%pyproject_buildrequires\n%endif\n\n%description -n python%{python3_pkgversion}-%{pypi_name} %{common_description}\n\n%prep\n%autosetup -n %{pypi_name}-%{version}\n\n%build\n%if %{legacy_python_build}\n# backwards compatible\n%py3_build\n%else\n%pyproject_wheel\n%endif\n\n%install\n%if %{legacy_python_build}\n# backwards compatible\n%py3_install\n# Compile gettext catalogues from SOURCE into the INSTALLED tree\npushd %{_builddir}/%{pypi_name}-%{version}\nfor po in apprise/i18n/*/LC_MESSAGES/apprise.po; do\n    [ -f \"$po\" ] || continue\n    langdir=\"$(dirname \"${po#apprise/i18n/}\")\"\n    outdir=\"%{buildroot}%{python3_sitelib}/%{pypi_name}/i18n/${langdir}\"\n    install -d \"$outdir\"\n    msguniq --use-first -o \"$po\" \"$po\"\n    msgfmt -o \"${outdir}/apprise.mo\" \"$po\"\ndone\n%else\n%pyproject_install\n%pyproject_save_files apprise\n\n# Compile gettext catalogues into the installed tree\npushd %{buildroot}%{python3_sitelib}/apprise/i18n\nfor po in */LC_MESSAGES/apprise.po; do\n    [ -f \"$po\" ] || continue\n    msguniq --use-first -o \"$po\" \"$po\"\n    msgfmt -o \"${po%.po}.mo\" \"$po\"\ndone\n%endif\n\npopd\n\n%{__install} -p -D -T -m 0644 packaging/man/%{pypi_name}.1 \\\n   %{buildroot}%{_mandir}/man1/%{pypi_name}.1\n\n%if %{with tests}\n%check\n%if %{legacy_python_build}\n# backwards compatible\nLANG=C.UTF-8 PYTHONPATH=%{buildroot}%{python3_sitelib}:%{_builddir}/%{name}-%{version} py.test-%{python3_version}\n%else\n%pytest\n%endif\n%endif\n\n%files -n python%{python3_pkgversion}-%{pypi_name}\n%license LICENSE\n%doc SECURITY.md README.md ACKNOWLEDGEMENTS.md CONTRIBUTING.md\n%{python3_sitelib}/%{pypi_name}/\n# Exclude i18n as it is handled below with the lang(spoken) tag below\n%exclude %{python3_sitelib}/%{pypi_name}/cli.*\n%exclude %{python3_sitelib}/%{pypi_name}/__pycache__/cli*.py?\n\n%if %{legacy_python_build}\n# Handle egg-info vs. dist-info based on build backend\n%{python3_sitelib}/apprise-*.egg-info\n# Legacy: include all compiled locales that we produced under the package tree\n%lang(en) %{python3_sitelib}/%{pypi_name}/i18n/en/LC_MESSAGES/apprise.mo\n%else\n# Handle egg-info vs. dist-info based on build backend\n%{python3_sitelib}/apprise-*.dist-info/\n# Localised Files\n%exclude %{python3_sitelib}/%{pypi_name}/i18n/\n%lang(en) %{python3_sitelib}/%{pypi_name}/i18n/en/LC_MESSAGES/apprise.mo\n%endif\n\n%files -n %{pypi_name}\n%{_bindir}/%{pypi_name}\n%{_mandir}/man1/%{pypi_name}.1*\n%{python3_sitelib}/%{pypi_name}/cli.py\n%{python3_sitelib}/%{pypi_name}/__pycache__/cli*.py?\n\n%changelog\n* Sun Mar  8 2026 Chris Caron <lead2gold@gmail.com> - 1.9.8-1\n- Updated to v1.9.8\n\n* Tue Jan 20 2026 Chris Caron <lead2gold@gmail.com> - 1.9.7-1\n- Updated to v1.9.7\n\n* Sun Jan 18 2026 Benjamin A. Beasley <code@musicinmybrain.net> - 1.9.6-3\n- Remove unnecessary pytest-runner, pytest-cov dependencies\n\n* Sat Jan 17 2026 Fedora Release Engineering <releng@fedoraproject.org> - 1.9.6-2\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_44_Mass_Rebuild\n\n* Sun Dec  7 2025 Chris Caron <lead2gold@gmail.com> - 1.9.6-1\n- Updated to v1.9.6\n\n* Tue Sep 30 2025 Chris Caron <lead2gold@gmail.com> - 1.9.5-1\n- Updated to v1.9.5\n\n* Fri Sep 19 2025 Python Maint <python-maint@redhat.com> - 1.9.4-4\n- Rebuilt for Python 3.14.0rc3 bytecode\n\n* Sat Aug 16 2025 Chris Caron <lead2gold@gmail.com> - 1.9.4-3\n- Spec file modernization BZ2377453\n- Translation files patch added to allow v1.9.4 to build corectly\n\n* Fri Aug 15 2025 Python Maint <python-maint@redhat.com> - 1.9.4-2\n- Rebuilt for Python 3.14.0rc2 bytecode\n\n* Sat Aug  2 2025 Chris Caron <lead2gold@gmail.com> - 1.9.4\n- Updated to v1.9.4\n\n* Sun Mar 30 2025 Chris Caron <lead2gold@gmail.com> - 1.9.3\n- Updated to v1.9.3\n\n* Sat Jan 18 2025 Fedora Release Engineering <releng@fedoraproject.org> - 1.9.1-2\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_42_Mass_Rebuild\n\n* Wed Jan  8 2025 Chris Caron <lead2gold@gmail.com> - 1.9.2\n- Updated to v1.9.2\n\n* Tue Dec 17 2024 Chris Caron <lead2gold@gmail.com> - 1.9.1\n- Updated to v1.9.1\n\n* Mon Sep  2 2024 Chris Caron <lead2gold@gmail.com> - 1.9.0\n- Updated to v1.9.0\n\n* Thu Jul 25 2024 Chris Caron <lead2gold@gmail.com> - 1.8.1\n- Updated to v1.8.1\n\n* Fri Jul 19 2024 Fedora Release Engineering <releng@fedoraproject.org> - 1.8.0-3\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_41_Mass_Rebuild\n\n* Fri Jun 07 2024 Python Maint <python-maint@redhat.com> - 1.8.0-2\n- Rebuilt for Python 3.13\n\n* Sat May 11 2024 Chris Caron <lead2gold@gmail.com> - 1.8.0\n- Updated to v1.8.0\n\n* Sat Apr 13 2024 Chris Caron <lead2gold@gmail.com> - 1.7.6\n- Updated to v1.7.6\n\n* Sat Mar 30 2024 Chris Caron <lead2gold@gmail.com> - 1.7.5\n- Updated to v1.7.5\n\n* Sat Mar  9 2024 Chris Caron <lead2gold@gmail.com> - 1.7.4\n- Updated to v1.7.4\n\n* Sun Mar  3 2024 Chris Caron <lead2gold@gmail.com> - 1.7.3\n- Updated to v1.7.3\n\n* Sat Jan 27 2024 Chris Caron <lead2gold@gmail.com> - 1.7.2\n- Updated to v1.7.2\n\n* Fri Jan 26 2024 Fedora Release Engineering <releng@fedoraproject.org> - 1.6.0-3\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild\n\n* Sun Jan 21 2024 Fedora Release Engineering <releng@fedoraproject.org> - 1.6.0-2\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild\n\n* Sun Oct 15 2023 Chris Caron <lead2gold@gmail.com> - 1.6.0\n- Updated to v1.6.0\n\n* Sun Aug 27 2023 Chris Caron <lead2gold@gmail.com> - 1.5.0\n- Updated to v1.5.0\n- apprise-fedora-rpm-testcase-handling.patch added for test handling\n\n* Fri Jul 21 2023 Fedora Release Engineering <releng@fedoraproject.org> - 1.4.5-2\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_39_Mass_Rebuild\n\n* Thu Jul  6 2023 Chris Caron <lead2gold@gmail.com> - 1.4.5\n- Updated to v1.4.5\n\n* Wed Jun 14 2023 Python Maint <python-maint@redhat.com> - 1.4.0-2\n- Rebuilt for Python 3.12\n\n* Mon May 15 2023 Chris Caron <lead2gold@gmail.com> - 1.4.0\n- Updated to v1.4.0\n\n* Wed Feb 22 2023 Chris Caron <lead2gold@gmail.com> - 1.3.0\n- Updated to v1.3.0\n\n* Fri Jan 20 2023 Fedora Release Engineering <releng@fedoraproject.org> - 1.2.1-2\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_38_Mass_Rebuild\n\n* Wed Dec 28 2022 Chris Caron <lead2gold@gmail.com> - 1.2.1-1\n- Updated to v1.2.1\n\n* Tue Nov 15 2022 Chris Caron <lead2gold@gmail.com> - 1.2.0-1\n- Updated to v1.2.0\n\n* Sat Oct  8 2022 Chris Caron <lead2gold@gmail.com> - 1.1.0-1\n- Updated to v1.1.0\n\n* Fri Oct  7 2022 Chris Caron <lead2gold@gmail.com> - 1.0.0-3\n- Python 2 Support dropped\n\n* Wed Aug 31 2022 Chris Caron <lead2gold@gmail.com> - 1.0.0-2\n- Rebuilt for RHEL9 Support\n\n* Sat Aug  6 2022 Chris Caron <lead2gold@gmail.com> - 1.0.0-1\n- Updated to v1.0.0\n\n* Fri Jul 22 2022 Fedora Release Engineering <releng@fedoraproject.org> - 0.9.9-3\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_37_Mass_Rebuild\n\n* Wed Jun 15 2022 Python Maint <python-maint@redhat.com> - 0.9.9-2\n- Rebuilt for Python 3.11\n\n* Thu Jun  2 2022 Chris Caron <lead2gold@gmail.com> - 0.9.9-1\n- Updated to v0.9.9\n\n* Thu Apr 28 2022 Chris Caron <lead2gold@gmail.com> - 0.9.8.3-1\n- Updated to v0.9.8.3\n\n* Sat Apr 23 2022 Chris Caron <lead2gold@gmail.com> - 0.9.8.2-1\n- Updated to v0.9.8.2\n\n* Tue Apr 19 2022 Chris Caron <lead2gold@gmail.com> - 0.9.8.1-1\n- Updated to v0.9.8.1\n\n* Mon Apr 18 2022 Chris Caron <lead2gold@gmail.com> - 0.9.8-1\n- Updated to v0.9.8\n\n* Wed Feb  2 2022 Chris Caron <lead2gold@gmail.com> - 0.9.7-1\n- Updated to v0.9.7\n\n* Wed Dec  1 2021 Chris Caron <lead2gold@gmail.com> - 0.9.6-1\n- Updated to v0.9.6\n\n* Sat Sep 18 2021 Chris Caron <lead2gold@gmail.com> - 0.9.5.1-2\n- Updated to v0.9.5.1\n\n* Sat Sep 18 2021 Chris Caron <lead2gold@gmail.com> - 0.9.5-1\n- Updated to v0.9.5\n\n* Wed Aug 11 2021 Chris Caron <lead2gold@gmail.com> - 0.9.4-1\n- Updated to v0.9.4\n\n* Fri Jul 23 2021 Fedora Release Engineering <releng@fedoraproject.org> - 0.9.3-3\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_35_Mass_Rebuild\n\n* Fri Jun 04 2021 Python Maint <python-maint@redhat.com> - 0.9.3-2\n- Rebuilt for Python 3.10\n\n* Sun May 16 2021 Chris Caron <lead2gold@gmail.com> - 0.9.3-1\n- Updated to v0.9.3\n\n* Sun May  2 2021 Chris Caron <lead2gold@gmail.com> - 0.9.2-1\n- Updated to v0.9.2\n\n* Tue Feb 23 2021 Chris Caron <lead2gold@gmail.com> - 0.9.1-2\n- Added missing cryptography dependency\n\n* Tue Feb 23 2021 Chris Caron <lead2gold@gmail.com> - 0.9.1-1\n- Updated to v0.9.1\n\n-* Wed Jan 27 2021 Fedora Release Engineering <releng@fedoraproject.org> - 0.9.0-3\n-- Rebuilt for https://fedoraproject.org/wiki/Fedora_34_Mass_Rebuild\n\n* Thu Jan 14 2021 Chris Caron <lead2gold@gmail.com> - 0.9.0-2\n- Fixed unit tests\n\n* Wed Dec 30 2020 Chris Caron <lead2gold@gmail.com> - 0.9.0-1\n- Updated to v0.9.0\n\n* Sun Oct  4 2020 Chris Caron <lead2gold@gmail.com> - 0.8.9-1\n- Updated to v0.8.9\n\n* Wed Sep  2 2020 Chris Caron <lead2gold@gmail.com> - 0.8.8-1\n- Updated to v0.8.8\n\n* Thu Aug 13 2020 Chris Caron <lead2gold@gmail.com> - 0.8.7-1\n- Updated to v0.8.7\n\n* Mon Aug 03 2020 Chris Caron <lead2gold@gmail.com> - 0.8.6-4\n- Updated SPEC so Fedora 33 Mass Rebuild would pass\n\n* Sat Aug 01 2020 Fedora Release Engineering <releng@fedoraproject.org> - 0.8.6-3\n- Second attempt - Rebuilt for\n  https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild\n\n* Tue Jul 28 2020 Fedora Release Engineering <releng@fedoraproject.org> - 0.8.6-2\n- Rebuilt for https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild\n\n* Sat Jun 13 2020 Chris Caron <lead2gold@gmail.com> - 0.8.6-1\n- Updated to v0.8.6\n\n* Tue May 26 2020 Miro Hrončok <mhroncok@redhat.com> - 0.8.5-2\n- Rebuilt for Python 3.9\n\n* Mon Mar 30 2020 Chris Caron <lead2gold@gmail.com> - 0.8.5-1\n- Updated to v0.8.5\n\n* Sat Feb  1 2020 Chris Caron <lead2gold@gmail.com> - 0.8.4-1\n- Updated to v0.8.4\n\n* Sun Jan 12 2020 Chris Caron <lead2gold@gmail.com> - 0.8.3-1\n- Updated to v0.8.3\n\n* Mon Nov 25 2019 Chris Caron <lead2gold@gmail.com> - 0.8.2-1\n- Updated to v0.8.2\n\n* Sun Oct 13 2019 Chris Caron <lead2gold@gmail.com> - 0.8.1-1\n- Updated to v0.8.1\n\n* Fri Sep 20 2019 Chris Caron <lead2gold@gmail.com> - 0.8.0-1\n- Updated to v0.8.0\n\n* Fri Jul 19 2019 Chris Caron <lead2gold@gmail.com> - 0.7.9-1\n- Updated to v0.7.9\n\n* Thu Jun  6 2019 Chris Caron <lead2gold@gmail.com> - 0.7.8-1\n- Updated to v0.7.8\n\n* Fri May 31 2019 Chris Caron <lead2gold@gmail.com> - 0.7.7-1\n- Updated to v0.7.7\n\n* Tue Apr 16 2019 Chris Caron <lead2gold@gmail.com> - 0.7.6-1\n- Updated to v0.7.6\n\n* Sun Apr  7 2019 Chris Caron <lead2gold@gmail.com> - 0.7.5-1\n- Updated to v0.7.5\n\n* Sun Mar 10 2019 Chris Caron <lead2gold@gmail.com> - 0.7.4-1\n- Updated to v0.7.4\n- Fedora review process added a man page, spec restructuring and 2 patch files\n  to accommodate some valid points brought forth. These have already been pushed\n  upstream and will be removed on the next version.\n\n* Fri Feb 22 2019 Chris Caron <lead2gold@gmail.com> - 0.7.3-1\n- Updated to v0.7.3\n- Added Python 3 build support\n\n* Sun Sep  9 2018 Chris Caron <lead2gold@gmail.com> - 0.5.0-1\n- Updated to v0.5.0\n\n* Sun Mar 11 2018 Chris Caron <lead2gold@gmail.com> - 0.0.8-1\n- Initial Release\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\n    \"setuptools>=69\",\n    \"wheel\",\n    \"babel\",\n]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"apprise\"\ndescription = \"Push Notifications that work with just about every platform!\"\nreadme = \"README.md\"\nauthors = [\n    { name = \"Chris Caron\", email = \"lead2gold@gmail.com\" },\n]\ndynamic = [\"version\"]\n\n# Not supported yet for all Distributions\nlicense = { text = \"BSD-2-Clause\" }\n\nrequires-python = \">=3.9\"\nclassifiers = [\n    \"Development Status :: 5 - Production/Stable\",\n    \"Intended Audience :: Developers\",\n    \"Intended Audience :: System Administrators\",\n    \"Operating System :: OS Independent\",\n    \"Natural Language :: English\",\n    \"Programming Language :: Python\",\n    \"Programming Language :: Python :: 3.9\",\n    \"Programming Language :: Python :: 3.10\",\n    \"Programming Language :: Python :: 3.11\",\n    \"Programming Language :: Python :: 3.12\",\n    \"Programming Language :: Python :: Implementation :: CPython\",\n    \"Programming Language :: Python :: Implementation :: PyPy\",\n    \"Topic :: Software Development :: Libraries :: Python Modules\",\n    \"Topic :: Software Development :: Libraries :: Application Frameworks\",\n]\n\ndependencies = [\n    # Application dependencies\n    \"requests\",\n    \"requests-oauthlib\",\n    \"click>=5.0\",\n    \"markdown\",\n    \"PyYAML\",\n    # Root certificate authority bundle\n    \"certifi\",\n    # Always ship IANA tzdb on Windows so ZoneInfo works\n    \"tzdata; platform_system == 'Windows'\",\n]\n\n# Identifies all of the supported plugins\nkeywords = [\n    \"46elks\",\n    \"Africas Talking\",\n    \"Alerts\",\n    \"Apprise API\",\n    \"Automated Packet Reporting System\",\n    \"AWS\",\n    \"Bark\",\n    \"BlueSky\",\n    \"Brevo\",\n    \"BulkSMS\",\n    \"BulkVS\",\n    \"Burst SMS\",\n    \"Chanify\",\n    \"Chat\",\n    \"CLI\",\n    \"Clickatell\",\n    \"ClickSend\",\n    \"D7Networks\",\n    \"Dapnet\",\n    \"DBus\",\n    \"DingTalk\",\n    \"Discord\",\n    \"Dot\",\n    \"Email\",\n    \"Emby\",\n    \"Enigma2\",\n    \"FCM\",\n    \"Feishu\",\n    \"Flock\",\n    \"Fluxer\",\n    \"Form\",\n    \"Free Mobile\",\n    \"Gnome\",\n    \"Google Chat\",\n    \"Gotify\",\n    \"Growl\",\n    \"Guilded\",\n    \"Home Assistant\",\n    \"httpSMS\",\n    \"IFTTT\",\n    \"IRC\",\n    \"Jellyfin\",\n    \"Join\",\n    \"JSON\",\n    \"Kavenegar\",\n    \"KODI\",\n    \"Kumulos\",\n    \"LaMetric\",\n    \"Lark\",\n    \"Line\",\n    \"MacOSX\",\n    \"Mailgun\",\n    \"Mastodon\",\n    \"Matrix\",\n    \"Mattermost\",\n    \"MessageBird\",\n    \"Microsoft\",\n    \"Misskey\",\n    \"MQTT\",\n    \"MSG91\",\n    \"MSTeams\",\n    \"Nextcloud\",\n    \"NextcloudTalk\",\n    \"Notica\",\n    \"NotificationAPI\",\n    \"Notifiarr\",\n    \"Notifico\",\n    \"Ntfy\",\n    \"Office365\",\n    \"OneSignal\",\n    \"Opsgenie\",\n    \"PagerDuty\",\n    \"PagerTree\",\n    \"ParsePlatform\",\n    \"Plivo\",\n    \"PopcornNotify\",\n    \"Power Automate\",\n    \"Prowl\",\n    \"Push Notifications\",\n    \"PushBullet\",\n    \"PushDeer\",\n    \"Pushed\",\n    \"Pushjet\",\n    \"PushMe\",\n    \"Pushover\",\n    \"Pushplus\",\n    \"PushSafer\",\n    \"Pushy\",\n    \"QQ Push\",\n    \"Quote/0\",\n    \"Reddit\",\n    \"Resend\",\n    \"Revolt\",\n    \"Rocket.Chat\",\n    \"RSyslog\",\n    \"Ryver\",\n    \"SendGrid\",\n    \"SendPulse\",\n    \"ServerChan\",\n    \"SES\",\n    \"Seven\",\n    \"SFR\",\n    \"Signal\",\n    \"SIGNL4\",\n    \"SimplePush\",\n    \"Sinch\",\n    \"Slack\",\n    \"SMPP\",\n    \"SMS Manager\",\n    \"SMSEagle\",\n    \"SMTP2Go\",\n    \"SNS\",\n    \"SparkPost\",\n    \"Spike\",\n    \"Splunk\",\n    \"SpugPush\",\n    \"Streamlabs\",\n    \"Stride\",\n    \"Synology Chat\",\n    \"Syslog\",\n    \"Techulus\",\n    \"Telegram\",\n    \"Threema Gateway\",\n    \"Twilio\",\n    \"Twist\",\n    \"Twitter\",\n    \"Vapid\",\n    \"Viber\",\n    \"VictorOps\",\n    \"Voipms\",\n    \"Vonage\",\n    \"Webex\",\n    \"Webpush\",\n    \"WeCom Bot\",\n    \"WhatsApp\",\n    \"Windows\",\n    \"Workflows\",\n    \"WxPusher\",\n    \"XBMC\",\n    \"XML\",\n    \"XMPP\",\n    \"Zulip\",\n]\n\n[project.optional-dependencies]\n\n# All packages required to test/build against\ndev = [\n    \"coverage\",\n    \"mock\",\n    \"tox\",\n    \"pytest\",\n    \"pytest-cov\",\n    \"pytest-mock\",\n    \"ruff\",\n    \"babel\",\n    \"validate-pyproject\",\n]\n\n# Defines all Python libraries for Apprise to work with\n# all plugins\nall-plugins = [\n    # Used in many applications requiring cryptography\n    # such as fcm://, splush:// and much more\n    \"cryptography\",\n\n    # provides growl:// support\n    \"gntp\",\n    # Provides mqtt:// support\n    # use any version other than 2.0.x due to:\n    #  - https://github.com/eclipse/paho.mqtt.python/issues/814\n    \"paho-mqtt != 2.0.*\",\n\n     # Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support\n    \"PGPy\",\n\n    # Provides smpp:// support\n    \"smpplib\",\n\n    # For xmpp:// support\n    \"slixmpp >= 1.10.0\",\n]\nwindows = [\n    \"pywin32\",\n    \"tzdata\",\n]\n\n[project.urls]\nHomepage = \"https://appriseit.com\"\nSource = \"https://github.com/caronc/apprise\"\nTracker = \"https://github.com/caronc/apprise/issues\"\nDocumentation = \"https://appriseit.com\"\n\n[project.scripts]\napprise = \"apprise.cli:main\"\n\n[tool.setuptools]\ninclude-package-data = true\n\n[tool.setuptools.package-data]\napprise = [\n    \"assets/NotifyXML-*.xsd\",\n    \"assets/themes/default/*.png\",\n    \"assets/themes/default/*.ico\",\n    \"i18n/*.py\",\n    \"i18n/*/LC_MESSAGES/*.mo\",\n    \"py.typed\",\n    \"*.pyi\",\n    \"*/*.pyi\",\n]\n\n[tool.setuptools.packages.find]\nwhere = [\n    \".\",\n]\nexclude = [\n    \"tests*\",\n    \"tools*\",\n]\ninclude = [\"apprise*\"]\n\n[tool.setuptools.dynamic]\nversion = {attr = \"apprise.__version__\"}\n\n[tool.ruff]\nline-length = 79  # Respecting BSD-style 79-char limit\ntarget-version = \"py39\"\nexclude = [\n  \"tests/data\",\n  \"bin\",\n  \"build\",\n  \"dist\",\n  \".eggs\",\n  \".tox\",\n  \".local\",\n  \".venv\",\n  \"venv\",\n]\n\n[tool.black]\n# Added for backwards support and alternative cleanup\n# when needed\nline-length = 79  # Respecting BSD-style 79-char limit\ntarget-version = ['py39']\nextend-exclude = '''\n/(\n  tests/data \\\n  bin \\\n  build \\\n  dist \\\n  .eggs \\\n  .tox \\\n  .local \\\n  .venv \\\n  venv\n)\n'''\n\n[tool.ruff.lint]\n# Allow Preview rules to run\npreview = true\n\nselect = [\n  \"E\",    # pycodestyle errors\n  \"F\",    # pyflakes\n  \"W\",    # pycodestyle warnings\n  \"Q\",    # Quote handling ' -> \"\n  \"I\",    # isort\n  \"UP\",   # pyupgrade\n  \"B\",    # flake8-bugbear\n  \"C4\",   # flake8-comprehensions\n  \"SIM\",  # flake8-simplify\n  \"T20\",  # flake8-print (catches stray `print`)\n  \"RUF\",  # Ruff-native rules\n]\n\nextend-select = [\n   \"E501\",   # Spacing\n   \"Q000\",   # Quoting\n   \"I\"  # Automatically sort imports with isort logic\n]\n\nignore = [\n  \"D100\",    # missing docstring in public module\n  \"D104\",    # missing docstring in public package\n  \"B008\",    # do not call `dict()` with keyword args\n  \"E722\",    # bare except (Apprise uses it reasonably)\n  \"E741\",    # Ambiguous variable name (e.g., l, O, I)\n  \"W605\",    # Invalid escape sequence\n  \"B026\",    # Star-arg play a big part of Apprise; must be accepted for now\n\n  # The following needs to be supported at some point and is of great value\n  \"RUF012\",  # typing.ClassVar implimentation\n  \"RUF029\",  # Use of async without await or async subcalls\n  \"RUF067\",  # Classes can only have docstrings and re-export only\n  \"UP032\",   # This is a massive undertaking and requires a massive rewrite\n             # of test cases; this will take a while to fix, so turning off\n             # for now\n]\n\n[tool.ruff.lint.pyupgrade]\nkeep-runtime-typing = true\n\n[tool.ruff.lint.flake8-quotes]\ninline-quotes = \"double\"\n\n[tool.ruff.lint.isort]\nknown-first-party = [\"apprise\"]\nforce-sort-within-sections = true\ncombine-as-imports = true\norder-by-type = true\nsection-order = [\"future\", \"standard-library\", \"third-party\", \"first-party\", \"local-folder\"]\n\n[tool.ruff.lint.flake8-builtins]\nbuiltins-ignorelist = [\"_\"]\n\n[tool.pytest.ini_options]\n# Keep pytest-cov minimal, let Coverage.py control formatting.\naddopts = \"-ra\"\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\", \"tests/test_*.py\"]\nfilterwarnings = [\"once::Warning\"]\n\n[tool.coverage.run]\nbranch = true\nparallel = false\ndata_file = \".coverage\"\nsource = [\"apprise\"]\nrelative_files = true\n\n[tool.coverage.paths]\n# Normalise .tox and site-packages layouts back to 'apprise'\nsource = [\n    \"apprise\",\n    \"/apprise/apprise\",\n    \".tox/*/lib/python*/site-packages/apprise\",\n    \".tox/pypy/site-packages/apprise\"\n]\n\n[tool.coverage.report]\n# Controls terminal and XML content when Coverage.py renders reports\nshow_missing = true\nskip_covered = true\nskip_empty = true\nprecision = 1\nexclude_lines = [\n    \"pragma: no cover\",\n    \"if TYPE_CHECKING:\",\n    \"if __name__ == .__main__.:\",\n    \"raise NotImplementedError\",\n    \"pass$\",\n]\n\n[tool.coverage.xml]\n# Ensure GitHub Action can find a predictable file\noutput = \"coverage.xml\"\n"
  },
  {
    "path": "requirements.txt",
    "content": "#\n# Note: This file is being kept for backwards compatibility with\n#       legacy systems that point here.  All future changes should\n#       occur in pyproject.toml.  Contents of this file can be found\n#       in project.dependencies\n\n# Root certificate authority bundle\ncertifi\n\n# Application dependencies\nrequests\nrequests-oauthlib\nclick >= 5.0\nmarkdown\nPyYAML\n"
  },
  {
    "path": "setup.py",
    "content": "#!/usr/bin/env python\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n#\n# 2025.07.10 NOTE:\n# setup.py (Temporary shim for RHEL9 Package Building Only)\n# Refer to tox for everything else; this will be removed once RHEL9 support\n# is dropped.\n#\nimport os\nimport re\n\nfrom setuptools import find_packages, setup\n\n\ndef read_version() -> str:\n    with open(os.path.join(\"apprise\", \"__init__.py\"), encoding=\"utf-8\") as f:\n        for line in f:\n            m = re.match(r'^__version__\\s*=\\s*[\\'\"]([^\\'\"]+)', line)\n            if m:\n                return m.group(1)\n    raise RuntimeError(\"Version not found\")\n\n\n# tox is not supported well in RHEL9 so this stub file is the only way to\n# successfully build the RPM; packaging/redhat/python-apprise.spec has\n# been updated accordingly to accommodate reference to this for older\n# versions of the distribution only\nsetup(\n    name=\"apprise\",\n    version=read_version(),\n    packages=find_packages(exclude=[\"tests*\", \"packaging*\"]),\n    entry_points={\n        \"console_scripts\": [\n            \"apprise = apprise.cli:main\",\n        ],\n    },\n    package_data={\n        \"apprise\": [\n            \"assets/NotifyXML-*.xsd\",\n            \"assets/themes/default/*.png\",\n            \"assets/themes/default/*.ico\",\n            \"i18n/*.py\",\n            \"i18n/*/LC_MESSAGES/*.mo\",\n            \"py.typed\",\n            \"*.pyi\",\n            \"*/*.pyi\",\n        ],\n    },\n)\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport gc\nimport mimetypes\nimport os\nimport sys\n\nimport pytest\n\nfrom apprise import (\n    AttachmentManager,\n    ConfigurationManager,\n    NotificationManager,\n)\n\nsys.path.append(os.path.join(os.path.dirname(__file__), \"helpers\"))\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n# Grant access to our Config Manager Singleton\nC_MGR = ConfigurationManager()\n# Grant access to our Attachment Manager Singleton\nA_MGR = AttachmentManager()\n\n\n@pytest.fixture(scope=\"function\", autouse=True)\ndef mimetypes_always_available():\n    \"\"\"A pytest session fixture which ensures mimetypes is set correctly\n    pointing to our temporary mime.types file.\"\"\"\n    files = (os.path.join(os.path.dirname(__file__), \"var\", \"mime.types\"),)\n    mimetypes.init(files=files)\n\n\n@pytest.fixture(scope=\"function\", autouse=True)\ndef no_throttling_everywhere(session_mocker):\n    \"\"\"A pytest session fixture which disables throttling on all notifiers.\n\n    It is automatically enabled.\n    \"\"\"\n    # Ensure we're working with a clean slate for each test\n    N_MGR.unload_modules()\n    C_MGR.unload_modules()\n    A_MGR.unload_modules()\n\n    for plugin in N_MGR.plugins():\n        session_mocker.patch.object(plugin, \"request_rate_per_sec\", 0)\n\n\n@pytest.fixture(scope=\"function\", autouse=True)\ndef collect_all_garbage(session_mocker):\n    \"\"\"A pytest session fixture to ensure no __del__ cleanup call from one\n    plugin will cause testing issues with another.\n\n    Run garbage collection after every test\n    \"\"\"\n    # Force garbage collection\n    gc.collect()\n"
  },
  {
    "path": "tests/docker/Dockerfile.el10",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Base\nFROM rockylinux/rockylinux:10\nENV container=docker\n\n# Basic build tooling and RPM stack\nRUN \\\n    rm -f /lib/systemd/system/multi-user.target.wants/*;\\\n    rm -f /etc/systemd/system/*.wants/*;\\\n    rm -f /lib/systemd/system/local-fs.target.wants/*; \\\n    rm -f /lib/systemd/system/sockets.target.wants/*udev*; \\\n    rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \\\n    rm -f /lib/systemd/system/basic.target.wants/*;\\\n    rm -f /lib/systemd/system/anaconda.target.wants/*; \\\n    echo \"assumeyes=1\" >> /etc/yum.conf; \\\n    dnf -y update && \\\n    dnf install -y epel-release; \\\n    dnf install -y rpm-build python3-pip \\\n    dnf-plugins-core 'dnf-command(config-manager)' \\\n    'dnf-command(builddep)' sudo rsync rpmdevtools && \\\n    dnf config-manager --set-enabled crb && \\\n    dnf -y install pyproject-rpm-macros rpmlint rubygem-ronn-ng && \\\n    dnf clean all\n\n# Place our build file into the path\nCOPY packaging/redhat/python-apprise.spec /\nRUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \\\n    xargs dnf install -y && dnf clean all\n\n# RPM build structure setup\nENV FLAVOR=rpmbuild OS=centos DIST=el10\n\nRUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \\\n    echo \"builder ALL=(ALL:ALL) NOPASSWD:ALL\" >> /etc/sudoers\n\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\n\n# RPMs should never be built as root\nUSER builder\n"
  },
  {
    "path": "tests/docker/Dockerfile.el9",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Base\nFROM rockylinux/rockylinux:9\nENV container=docker\n\n# Basic build tooling and RPM stack\nRUN \\\n    rm -f /lib/systemd/system/multi-user.target.wants/*;\\\n    rm -f /etc/systemd/system/*.wants/*;\\\n    rm -f /lib/systemd/system/local-fs.target.wants/*; \\\n    rm -f /lib/systemd/system/sockets.target.wants/*udev*; \\\n    rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \\\n    rm -f /lib/systemd/system/basic.target.wants/*;\\\n    rm -f /lib/systemd/system/anaconda.target.wants/*; \\\n    echo \"assumeyes=1\" >> /etc/yum.conf; \\\n    dnf -y update && \\\n    dnf install -y epel-release; \\\n    dnf install -y rpm-build rpmlint python3-pip rubygem-ronn \\\n    dnf-plugins-core 'dnf-command(config-manager)' \\\n    'dnf-command(builddep)' sudo rsync rpmdevtools; \\\n    dnf config-manager --set-enabled crb\n\n# Place our build file into the path\nCOPY packaging/redhat/python-apprise.spec /\nRUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \\\n    xargs dnf install -y && dnf clean all\n\n# RPM Build Structure Setup\nENV FLAVOR=rpmbuild OS=centos DIST=el9\nRUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \\\n    echo \"builder ALL=(ALL:ALL) NOPASSWD:ALL\" >> /etc/sudoers\n\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\n\n# RPMs should never be built as root\nUSER builder\n"
  },
  {
    "path": "tests/docker/Dockerfile.f42",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n## The following was added to accommodate:\n#   https://bugzilla.redhat.com/show_bug.cgi?id=2216807\n#\n# the switch was added and it didn't work:\n#  dnf update -y --setopt=protected_packages=,\n#\n# The second work-around was to add --skip-broken\n# This also didn't work.  the final option was to download the RPMs in\n# advance and just force them.\n#\n# Base\nFROM fedora:42\nENV container=docker\n\n# https://bugzilla.redhat.com/show_bug.cgi?id=2216807 workaround\nRUN dnf download -y --destdir BZ2216807 --resolve dnf-data && \\\n    rpm -Uhi --force BZ2216807/*\n\nRUN \\\n    rm -f /usr/lib/systemd/system/multi-user.target.wants/*;\\\n    rm -f /etc/systemd/system/*.wants/*;\\\n    rm -f /usr/lib/systemd/system/local-fs.target.wants/*; \\\n    rm -f /usr/lib/systemd/system/sockets.target.wants/*udev*; \\\n    rm -f /usr/lib/systemd/system/sockets.target.wants/*initctl*; \\\n    rm -f /usr/lib/systemd/system/basic.target.wants/*;\\\n    rm -f /usr/lib/systemd/system/anaconda.target.wants/*; \\\n    echo \"assumeyes=1\" >> /etc/dnf/dnf.conf; \\\n    dnf install -y epel-release; \\\n    dnf install -y rpm-build rpmlint python3-pip rubygem-ronn \\\n    dnf-plugins-core 'dnf-command(config-manager)' \\\n    'dnf-command(builddep)' sudo rsync rpmdevtools\n\n# Place our build file into the path\nCOPY packaging/redhat/python-apprise.spec /\nRUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \\\n    xargs dnf install -y && dnf clean all\n\n# RPM Build Structure Setup\nENV FLAVOR=rpmbuild OS=centos DIST=f42\nRUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \\\n    echo \"builder ALL=(ALL:ALL) NOPASSWD:ALL\" >> /etc/sudoers\n\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\n\n# RPMs should never be built as root\nUSER builder\n"
  },
  {
    "path": "tests/docker/Dockerfile.py310",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Base\nFROM python:3.10-buster\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \\\n    rm -rf /var/lib/apt/lists/*\nRUN pip install --no-cache-dir dbus-python \"PyGObject==3.44.2\"\n\n# Apprise Setup\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\nCOPY requirements.txt /\nCOPY dev-requirements.txt /\nENV PYTHONPATH=/apprise\nENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py310\n\nRUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt\n\nRUN addgroup --gid ${USER_GID:-1000} apprise\nRUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise\n\nUSER apprise\n"
  },
  {
    "path": "tests/docker/Dockerfile.py311",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Base\nFROM python:3.11-buster\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \\\n    rm -rf /var/lib/apt/lists/*\nRUN pip install --no-cache-dir dbus-python \"PyGObject==3.44.2\"\n\n# Apprise Setup\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\nCOPY requirements.txt /\nCOPY dev-requirements.txt /\nENV PYTHONPATH=/apprise\nENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py311\n\nRUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt\n\nRUN addgroup --gid ${USER_GID:-1000} apprise\nRUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise\n\nUSER apprise\n"
  },
  {
    "path": "tests/docker/Dockerfile.py312",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Base\nFROM python:3.12-bookworm\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \\\n    rm -rf /var/lib/apt/lists/*\nRUN pip install --no-cache-dir dbus-python \"PyGObject==3.44.2\"\n\n# Apprise Setup\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\nCOPY requirements.txt /\nCOPY dev-requirements.txt /\nENV PYTHONPATH=/apprise\nENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py312\n\nRUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt\n\nRUN addgroup --gid ${USER_GID:-1000} apprise\nRUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise\n\nUSER apprise\n"
  },
  {
    "path": "tests/docker/Dockerfile.py39",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Base\nFROM python:3.9-bookworm\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends libdbus-1-dev libgirepository1.0-dev build-essential musl-dev bash dbus && \\\n    rm -rf /var/lib/apt/lists/*\nRUN pip install --no-cache-dir dbus-python \"PyGObject==3.44.2\"\n\n# Apprise Setup\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\nCOPY requirements.txt /\nCOPY dev-requirements.txt /\nENV PYTHONPATH=/apprise\nENV PYTHONPYCACHEPREFIX=/apprise/__pycache__/py39\n\nRUN pip install --no-cache-dir -r /requirements.txt -r /dev-requirements.txt\n\nRUN addgroup --gid ${USER_GID:-1000} apprise\nRUN adduser --system --uid ${USER_UID:-1000} --ingroup apprise --home /apprise --no-create-home --disabled-password apprise\n\nUSER apprise\n"
  },
  {
    "path": "tests/docker/Dockerfile.rawhide",
    "content": "# -*- coding: utf-8 -*-\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n## The following was added to accommodate:\n#   https://bugzilla.redhat.com/show_bug.cgi?id=2216807\n#\n# the switch was added and it didn't work:\n#  dnf update -y --setopt=protected_packages=,\n#\n# The second work-around was to add --skip-broken\n# This also didn't work.  the final option was to download the RPMs in\n# advance and just force them.\n#\n# Base\nFROM fedora:rawhide\nENV container=docker\n\n# https://bugzilla.redhat.com/show_bug.cgi?id=2216807 workaround\nRUN dnf download -y --destdir BZ2216807 --resolve dnf-data && \\\n    rpm -Uhi --force BZ2216807/*\n\nRUN \\\n    rm -f /usr/lib/systemd/system/multi-user.target.wants/*;\\\n    rm -f /etc/systemd/system/*.wants/*;\\\n    rm -f /usr/lib/systemd/system/local-fs.target.wants/*; \\\n    rm -f /usr/lib/systemd/system/sockets.target.wants/*udev*; \\\n    rm -f /usr/lib/systemd/system/sockets.target.wants/*initctl*; \\\n    rm -f /usr/lib/systemd/system/basic.target.wants/*;\\\n    rm -f /usr/lib/systemd/system/anaconda.target.wants/*; \\\n    echo \"assumeyes=1\" >> /etc/dnf/dnf.conf; \\\n    dnf install -y epel-release; \\\n    dnf install -y rpm-build rpmlint python3-pip rubygem-ronn \\\n    dnf-plugins-core 'dnf-command(config-manager)' \\\n    'dnf-command(builddep)' sudo rsync rpmdevtools\n\n# Place our build file into the path\nCOPY packaging/redhat/python-apprise.spec /\nRUN rpmspec -q --buildrequires /python-apprise.spec | cut -f1 -d' ' | \\\n    xargs dnf install -y && dnf clean all\n\n# RPM Build Structure Setup\nENV FLAVOR=rpmbuild OS=centos DIST=rawhide\nRUN useradd builder -u 1000 -m -G users,wheel &>/dev/null && \\\n    echo \"builder ALL=(ALL:ALL) NOPASSWD:ALL\" >> /etc/sudoers\n\nVOLUME [\"/apprise\"]\nWORKDIR /apprise\n\n# RPMs should never be built as root\nUSER builder\n"
  },
  {
    "path": "tests/helpers/__init__.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom .asyncio import OuterEventLoop\nfrom .environment import environ\nfrom .module import reload_plugin\nfrom .rest import AppriseURLTester\n\n__all__ = [\n    \"AppriseURLTester\",\n    \"OuterEventLoop\",\n    \"environ\",\n    \"reload_plugin\",\n]\n"
  },
  {
    "path": "tests/helpers/asyncio.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport asyncio\n\n\nclass OuterEventLoop:\n    \"\"\"An event loop that is easy to put up and tear down from synchronous test\n    code.\"\"\"\n\n    def __init__(self):\n        self._loop = None\n\n    def __enter__(self):\n        loop = asyncio.new_event_loop()\n        asyncio.set_event_loop(loop)\n        self._loop = loop\n        return loop\n\n    def __exit__(self, type, value, traceback):\n        asyncio.set_event_loop(None)\n        self._loop.close()\n"
  },
  {
    "path": "tests/helpers/environment.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport contextlib\nimport locale\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\n\nlogging.disable(logging.CRITICAL)\n\n\n@contextlib.contextmanager\ndef environ(*remove, **update):\n    \"\"\"Temporarily updates the ``os.environ`` dictionary in-place.\n\n    The ``os.environ`` dictionary is updated in-place so that the modification\n    is sure to work in all situations.\n\n    :param remove: Environment variable(s) to remove.\n    :param update: Dictionary of environment variables and values to\n                   add/update.\n    \"\"\"\n    env_orig = os.environ.copy()\n    loc_orig = locale.getlocale()\n    try:\n        os.environ.update(update)\n        for k in remove:\n            os.environ.pop(k, None)\n        yield\n    finally:\n        os.environ.clear()\n        os.environ.update(env_orig)\n        with contextlib.suppress(locale.Error):\n            locale.setlocale(locale.LC_ALL, loc_orig)\n"
  },
  {
    "path": "tests/helpers/module.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom importlib import import_module, reload\nfrom itertools import chain\nimport re\nimport sys\n\nfrom apprise import (\n    AttachmentManager,\n    ConfigurationManager,\n    NotificationManager,\n)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n# Grant access to our Attachment Manager Singleton\nA_MGR = AttachmentManager()\n\n# Grant access to our Configuration Manager Singleton\nC_MGR = ConfigurationManager()\n\n# For filtering our result when scanning a module\n# Identify any items below we should match on that we can freely\n# directly copy around between our modules.  This should only\n# catch class/function/variables we want to allow explicity\n# copy/paste access with\nmodule_filter_re = re.compile(\n    r\"^(?P<name>(Notify|Config|Attach)[A-Za-z0-9]+)$\"\n)\n\n\ndef reload_plugin(name):\n    \"\"\"Reload built-in plugin module, e.g. `NotifyGnome`.\n\n    Reloading plugin modules is needed when testing module-level code of\n    notification plugins.\n\n    See also\n    https://stackoverflow.com/questions/31363311.\n    \"\"\"\n\n    A_MGR.unload_modules()\n\n    reload(sys.modules[\"apprise.apprise_attachment\"])\n    reload(sys.modules[\"apprise.attachment.base\"])\n    new_apprise_attachment_mod = import_module(\"apprise.apprise_attachment\")\n    new_apprise_attach_base_mod = import_module(\"apprise.attachment.base\")\n    reload(sys.modules[\"apprise.manager_attachment\"])\n\n    module_pyname = f\"{N_MGR.module_name_prefix}.{name}\"\n    if module_pyname in sys.modules:\n        reload(sys.modules[module_pyname])\n    new_notify_mod = import_module(module_pyname)\n\n    A_MGR.unload_modules()\n\n    reload(sys.modules[\"apprise.apprise_config\"])\n    reload(sys.modules[\"apprise.config.base\"])\n    new_apprise_configuration_mod = import_module(\"apprise.apprise_config\")\n    new_apprise_config_base_mod = import_module(\"apprise.config.base\")\n    reload(sys.modules[\"apprise.manager_config\"])\n\n    C_MGR.unload_modules()\n\n    module_pyname = f\"{N_MGR.module_name_prefix}.{name}\"\n    if module_pyname in sys.modules:\n        reload(sys.modules[module_pyname])\n    new_notify_mod = import_module(module_pyname)\n\n    # Detect our class object\n    class_matches = {}\n    for class_name in [\n        obj for obj in dir(new_notify_mod) if module_filter_re.match(obj)\n    ]:\n\n        # Store our entry\n        class_matches[class_name] = getattr(new_notify_mod, class_name)\n\n    # the user running the tests did not correctly use reload_plugin() or\n    # they did, but libraries around them have shifted.  We need to error out\n    # so the test can be fixed\n    if not class_matches:\n        raise AttributeError(f\"Module {name} has no URLBase defined in it!\")\n\n    reload(sys.modules[\"apprise.manager_plugins\"])\n    reload(sys.modules[\"apprise.apprise\"])\n    reload(sys.modules[\"apprise.utils\"])\n    reload(sys.modules[\"apprise.locale\"])\n    reload(sys.modules[\"apprise\"])\n\n    # Acquire all of the test files we have\n    tests = [k for k in sys.modules if re.match(r\"^test_.+$\", k)]\n\n    # Iterate over all of our test modules\n    for module_name in tests:\n        # Filter the test files by only those using the class_name we found\n        # within our module\n        possible_matches = [\n            m\n            for m in dir(sys.modules[module_name])\n            if re.match(\n                \"^(?P<name>{})$\".format(\"|\".join(class_matches.keys())), m\n            )\n        ]\n        if not possible_matches:\n            continue\n\n        # if we get here, we have test_ files that utilize the Class we just\n        # reloaded\n\n        # Fix reference to new plugin class in given module.\n        # Needed for updating the module-level import reference like\n        # `from apprise.plugins.NotifyABCDE import NotifyABCDE`.\n        #\n        # We reload NotifyABCDE and place it back in its spot\n        test_mod = import_module(module_name)\n        for class_name, class_plugin in class_matches.items():\n            if hasattr(test_mod, class_name):\n                setattr(test_mod, class_name, class_plugin)\n\n    #\n    # Detect our Apprise Modules (include helpers)\n    #\n    apprise_modules = sorted(\n        [k for k in sys.modules if re.match(r\"^(apprise|helpers)(\\.|.+)$\", k)],\n        reverse=True,\n    )\n\n    #\n    # This section below reloads our attachment classes\n    #\n\n    for entry in A_MGR:\n        reload(sys.modules[entry[\"path\"]])\n        for module_pyname in chain(apprise_modules, tests):\n            detect = re.compile(\n                r\"^(?P<name>(AppriseAttachment|AttachBase|\"\n                + entry[\"path\"].split(\".\")[-1]\n                + r\"))$\"\n            )\n\n            possible_matches = [\n                m for m in dir(sys.modules[module_pyname]) if detect.match(m)\n            ]\n            if not possible_matches:\n                continue\n\n            apprise_mod = import_module(module_pyname)\n            # Fix reference to new plugin class in given module.\n            # Needed for updating the module-level import reference\n            # like `from apprise.<etc> import AttachABCDE`.\n            #\n            # We reload NotifyABCDE and place it back in its spot\n            # new_attach = import_module(entry['path'])\n            for name in possible_matches:\n                if name == \"AppriseAttachment\":\n                    setattr(\n                        apprise_mod,\n                        name,\n                        getattr(new_apprise_attachment_mod, name),\n                    )\n\n                elif name == \"AttachBase\":\n                    setattr(\n                        apprise_mod,\n                        name,\n                        getattr(new_apprise_attach_base_mod, name),\n                    )\n\n                else:\n                    module_pyname = f\"{A_MGR.module_name_prefix}.{name}\"\n                    new_attach_mod = import_module(module_pyname)\n\n                    # Detect our class object\n                    class_matches = {}\n                    for class_name in [\n                        obj\n                        for obj in dir(new_attach_mod)\n                        if module_filter_re.match(obj)\n                    ]:\n\n                        # Store our entry\n                        class_matches[class_name] = getattr(\n                            new_attach_mod, class_name\n                        )\n\n                    for class_name, class_plugin in class_matches.items():\n                        if hasattr(apprise_mod, class_name):\n                            setattr(apprise_mod, class_name, class_plugin)\n\n    #\n    # This section below reloads our configuration classes\n    #\n\n    for entry in C_MGR:\n        reload(sys.modules[entry[\"path\"]])\n        for module_pyname in chain(apprise_modules, tests):\n            detect = re.compile(\n                r\"^(?P<name>(AppriseConfig|ConfigBase|\"\n                + entry[\"path\"].split(\".\")[-1]\n                + r\"))$\"\n            )\n\n            possible_matches = [\n                m for m in dir(sys.modules[module_pyname]) if detect.match(m)\n            ]\n            if not possible_matches:\n                continue\n\n            apprise_mod = import_module(module_pyname)\n            # Fix reference to new plugin class in given module.\n            # Needed for updating the module-level import reference\n            # like `from apprise.<etc> import ConfigABCDE`.\n            #\n            # We reload NotifyABCDE and place it back in its spot\n            # new_attach = import_module(entry['path'])\n            for name in possible_matches:\n                if name == \"AppriseConfig\":\n                    setattr(\n                        apprise_mod,\n                        name,\n                        getattr(new_apprise_configuration_mod, name),\n                    )\n\n                elif name == \"ConfigBase\":\n                    setattr(\n                        apprise_mod,\n                        name,\n                        getattr(new_apprise_config_base_mod, name),\n                    )\n\n                else:\n                    module_pyname = f\"{A_MGR.module_name_prefix}.{name}\"\n                    new_config_mod = import_module(module_pyname)\n\n                    # Detect our class object\n                    class_matches = {}\n                    for class_name in [\n                        obj\n                        for obj in dir(new_config_mod)\n                        if module_filter_re.match(obj)\n                    ]:\n\n                        # Store our entry\n                        class_matches[class_name] = getattr(\n                            new_config_mod, class_name\n                        )\n\n                    for class_name, class_plugin in class_matches.items():\n                        if hasattr(apprise_mod, class_name):\n                            setattr(apprise_mod, class_name, class_plugin)\n"
  },
  {
    "path": "tests/helpers/rest.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom random import choice\nimport re\nfrom string import ascii_uppercase as str_alpha, digits as str_num\nfrom unittest import mock\n\nimport requests\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    AppriseAttachment,\n    NotifyBase,\n    NotifyType,\n    PersistentStoreMode,\n)\nfrom apprise.common import OverflowMode\n\nlogging.disable(logging.CRITICAL)\n\n\nclass AppriseURLTester:\n\n    # Some exception handling we'll use\n    req_exceptions = (\n        requests.ConnectionError(0, \"requests.ConnectionError() not handled\"),\n        requests.RequestException(\n            0, \"requests.RequestException() not handled\"\n        ),\n        requests.HTTPError(0, \"requests.HTTPError() not handled\"),\n        requests.ReadTimeout(0, \"requests.ReadTimeout() not handled\"),\n        requests.TooManyRedirects(\n            0, \"requests.TooManyRedirects() not handled\"\n        ),\n    )\n\n    # Attachment Testing Directory\n    __test_var_dir = os.path.join(\n        os.path.dirname(os.path.dirname(__file__)), \"var\"\n    )\n\n    # Our URLs we'll test against\n    __tests = []\n\n    # Define how many characters exist per line\n    row = 80\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    def __init__(self, tests=None, *args, **kwargs):\n        \"\"\"Our initialization.\"\"\"\n        # Create a large body and title with random data\n        self.body = \"\".join(\n            choice(str_alpha + str_num + \" \") for _ in range(self.body_len)\n        )\n        self.body = \"\\r\\n\".join([\n            self.body[i : i + self.row]\n            for i in range(0, len(self.body), self.row)\n        ])\n\n        # Create our title using random data\n        self.title = \"\".join(\n            choice(str_alpha + str_num) for _ in range(self.title_len)\n        )\n\n        if tests:\n            self.__tests = tests\n\n    def add(self, url, meta):\n        \"\"\"Adds a test suite to our object.\"\"\"\n        self.__tests.append({\n            \"url\": url,\n            \"meta\": meta,\n        })\n\n    def run_all(self, tmpdir=None):\n        \"\"\"Run all of our tests.\"\"\"\n        # iterate over our dictionary and test it out\n        for url, meta in self.__tests:\n            self.run(url, meta, tmpdir)\n\n    @mock.patch(\"requests.get\")\n    @mock.patch(\"requests.post\")\n    @mock.patch(\"requests.request\")\n    def run(self, url, meta, tmpdir, mock_request, mock_post, mock_get):\n        \"\"\"Run a specific test.\"\"\"\n\n        if meta is False:\n            # Prepare a default structure to make life easy\n            meta = {\n                \"instance\": TypeError,\n            }\n\n        # Our expected instance\n        instance = meta.get(\"instance\")\n\n        # Our expected server objects\n        self_ = meta.get(\"self\")\n\n        # Our expected privacy url\n        # Don't set this if don't need to check it's value\n        privacy_url = meta.get(\"privacy_url\")\n\n        # Our regular expression\n        url_matches = meta.get(\"url_matches\")\n\n        # Detect our storage path (used to set persistent storage\n        # mode\n        storage_path = (\n            tmpdir\n            if tmpdir and isinstance(tmpdir, str) and os.path.isdir(tmpdir)\n            else None\n        )\n\n        # Our storage mode to set\n        storage_mode = meta.get(\n            \"storage_mode\",\n            (\n                PersistentStoreMode.MEMORY\n                if not storage_path\n                else PersistentStoreMode.AUTO\n            ),\n        )\n\n        # Debug Mode\n        pdb = meta.get(\"pdb\", False)\n\n        # Whether or not we should include an image with our request; unless\n        # otherwise specified, we assume that images are to be included\n        include_image = meta.get(\"include_image\", True)\n        if include_image:\n            # a default asset\n            asset = AppriseAsset(\n                storage_mode=storage_mode,\n                storage_path=storage_path,\n            )\n\n        else:\n            # Disable images\n            asset = AppriseAsset(\n                image_path_mask=False,\n                image_url_mask=False,\n                storage_mode=storage_mode,\n                storage_path=storage_path,\n            )\n            asset.image_url_logo = None\n\n        # Mock our request object\n        robj = mock.Mock()\n        robj.content = \"\"\n        mock_get.return_value = robj\n        mock_post.return_value = robj\n        mock_request.return_value = robj\n\n        if pdb:\n            # Makes it easier to debug with this peice of code\n            # just add `pdb': True to the call that is failing\n            import pdb\n\n            pdb.set_trace()\n\n        try:\n            # We can now instantiate our object:\n            obj = Apprise.instantiate(\n                url, asset=asset, suppress_exceptions=False\n            )\n\n        except Exception as e:\n            # Handle our exception\n            if instance is None:\n                raise e\n\n            if not isinstance(e, instance):\n                raise e\n\n            # We're okay if we get here\n            return\n\n        if obj is None:\n            if instance is not None:\n                # We're done (assuming this is what we were\n                # expecting)\n                raise AssertionError()\n            # We're done because we got the results we expected\n            return\n\n        if instance is None:\n            # Expected None but didn't get it\n            raise AssertionError()\n\n        if not isinstance(obj, instance):\n            raise AssertionError()\n\n        if isinstance(obj, NotifyBase):\n            # Ensure we are not performing any type of thorttling\n            obj.request_rate_per_sec = 0\n\n            # We loaded okay; now lets make sure we can reverse\n            # this url\n            assert isinstance(obj.url(), str) is True\n\n            # Test that we support a url identifier\n            url_id = obj.url_id()\n\n            # It can be either disabled or a string; nothing else\n            assert isinstance(url_id, str) or (\n                url_id is None and obj.url_identifier is False\n            )\n\n            # Verify we can acquire a target count as an integer\n            assert isinstance(len(obj), int)\n\n            # Test url() with privacy=True\n            assert isinstance(obj.url(privacy=True), str) is True\n\n            # Some Simple Invalid Instance Testing\n            assert instance.parse_url(None) is None\n            assert instance.parse_url(object) is None\n            assert instance.parse_url(42) is None\n\n            # Assess that our privacy url is as expected\n            if privacy_url and not obj.url(privacy=True).startswith(\n                privacy_url\n            ):\n                raise AssertionError(\n                    f\"URL: {url} Privacy URL:\"\n                    f\" '{obj.url(privacy=True)[:len(privacy_url)]}' !=\"\n                    f\" expected '{privacy_url}'\"\n                )\n\n            # Assess that our URL matches a set regex\n            if url_matches and not re.search(url_matches, obj.url()):\n                raise AssertionError(\n                    f\"URL: {url} generated an reloadable \"\n                    f\"url() of {obj.url()} that does not match \"\n                    f\"'{url_matches}'\"\n                )\n\n            # Instantiate the exact same object again using the URL\n            # from the one that was already created properly\n            obj_cmp = Apprise.instantiate(obj.url())\n\n            if not isinstance(obj_cmp, NotifyBase):\n                raise AssertionError(\n                    f\"URL: {url} generated an un-reloadable \"\n                    f\"url() of {obj.url()}\"\n                )\n\n            # Our new object should produce the same url identifier\n            elif obj.url_identifier != obj_cmp.url_identifier:\n                raise AssertionError(\n                    f\"URL: {url} URL Identifier: \"\n                    f\"'{obj_cmp.url_identifier}' != expected\"\n                    f\" '{obj.url_identifier}'\"\n                )\n\n            # Back our check up\n            if obj.url_id() != obj_cmp.url_id():\n                raise AssertionError(\n                    f\"URL: {url} URL ID(): '{obj_cmp.url_id()}' != expected\"\n                    f\" '{obj.url_id()}'\"\n                )\n\n            # Verify there is no change from the old and the new\n            if len(obj) != len(obj_cmp):\n                raise AssertionError(\n                    f\"URL: {url} generated an reloadable \"\n                    f\"url() of {obj.url()} produced target miscount \"\n                    f\"{len(obj)} != {len(obj_cmp)}\"\n                )\n\n            # Tidy our object\n            del obj_cmp\n            del instance\n\n        if self_:\n            # Iterate over our expected entries inside of our\n            # object\n            for key, val in self_.items():\n                # Test that our object has the desired key\n                assert hasattr(obj, key) is True\n                assert getattr(obj, key) == val\n\n        try:\n            self.__notify(url, obj, meta, asset)\n\n        except AssertionError:\n            # Don't mess with these entries\n            raise\n\n        # Tidy our object and allow any possible defined destructors to\n        # be executed.\n        del obj\n\n    @mock.patch(\"requests.get\")\n    @mock.patch(\"requests.post\")\n    @mock.patch(\"requests.head\")\n    @mock.patch(\"requests.put\")\n    @mock.patch(\"requests.delete\")\n    @mock.patch(\"requests.patch\")\n    @mock.patch(\"requests.request\")\n    def __notify(\n        self,\n        url,\n        obj,\n        meta,\n        asset,\n        mock_request,\n        mock_patch,\n        mock_del,\n        mock_put,\n        mock_head,\n        mock_post,\n        mock_get,\n    ):\n        \"\"\"Perform notification testing against object specified.\"\"\"\n        #\n        # Prepare our options\n        #\n\n        # Allow notification type override, otherwise default to INFO\n        notify_type = meta.get(\"notify_type\", NotifyType.INFO)\n\n        # Whether or not we're testing exceptions or not\n        test_requests_exceptions = meta.get(\"test_requests_exceptions\", False)\n\n        # Our expected Query response (True, False, or exception type)\n        response = meta.get(\"response\", True)\n\n        # Our expected Notify response (True or False)\n        notify_response = meta.get(\"notify_response\", response)\n\n        # Our expected Notify Attachment response (True or False)\n        attach_response = meta.get(\"attach_response\", notify_response)\n\n        # Test attachments\n        # Don't set this if don't need to check it's value\n        check_attachments = meta.get(\"check_attachments\", True)\n\n        # Debug/Trace Monitoring\n        force_debug = meta.get(\"force_debug\", False)\n\n        # Allow us to force the server response code to be something other then\n        # the defaults\n        requests_response_code = meta.get(\n            \"requests_response_code\",\n            requests.codes.ok if response else requests.codes.not_found,\n        )\n\n        # Allow us to force the server response text to be something other then\n        # the defaults\n        requests_response_text = meta.get(\"requests_response_text\")\n        requests_response_content = None\n\n        if isinstance(requests_response_text, str):\n            requests_response_content = requests_response_text.encode(\"utf-8\")\n\n        elif isinstance(requests_response_text, bytes):\n            requests_response_content = requests_response_text\n            requests_response_text = requests_response_text.decode(\"utf-8\")\n\n        elif not isinstance(requests_response_text, str):\n            # Convert to string\n            requests_response_text = dumps(requests_response_text)\n            requests_response_content = requests_response_text.encode(\"utf-8\")\n\n        else:\n            requests_response_content = \"\"\n            requests_response_text = \"\"\n\n        # A request\n        robj = mock.Mock()\n        robj.content = \"\"\n        robj.text = \"\"\n        mock_get.return_value = robj\n        mock_post.return_value = robj\n        mock_head.return_value = robj\n        mock_patch.return_value = robj\n        mock_del.return_value = robj\n        mock_put.return_value = robj\n        mock_request.return_value = robj\n\n        if test_requests_exceptions is False:\n            # Handle our default response\n            mock_put.return_value.status_code = requests_response_code\n            mock_head.return_value.status_code = requests_response_code\n            mock_del.return_value.status_code = requests_response_code\n            mock_post.return_value.status_code = requests_response_code\n            mock_get.return_value.status_code = requests_response_code\n            mock_patch.return_value.status_code = requests_response_code\n            mock_request.return_value.status_code = requests_response_code\n\n            # Handle our default text response\n            mock_get.return_value.content = requests_response_content\n            mock_post.return_value.content = requests_response_content\n            mock_del.return_value.content = requests_response_content\n            mock_put.return_value.content = requests_response_content\n            mock_head.return_value.content = requests_response_content\n            mock_patch.return_value.content = requests_response_content\n            mock_request.return_value.content = requests_response_content\n\n            mock_get.return_value.text = requests_response_text\n            mock_post.return_value.text = requests_response_text\n            mock_put.return_value.text = requests_response_text\n            mock_del.return_value.text = requests_response_text\n            mock_head.return_value.text = requests_response_text\n            mock_patch.return_value.text = requests_response_text\n            mock_request.return_value.text = requests_response_text\n\n            # Ensure there is no side effect set\n            mock_post.side_effect = None\n            mock_del.side_effect = None\n            mock_put.side_effect = None\n            mock_head.side_effect = None\n            mock_get.side_effect = None\n            mock_patch.side_effect = None\n            mock_request.side_effect = None\n\n        else:\n            # Handle exception testing; first we turn the boolean flag\n            # into a list of exceptions\n            test_requests_exceptions = self.req_exceptions\n\n        try:\n            if test_requests_exceptions is False:\n\n                # Verify we can acquire a target count as an integer\n                targets = len(obj)\n\n                # check that we're as expected\n                resp = obj.notify(\n                    body=self.body, title=self.title, notify_type=notify_type\n                )\n                if resp != notify_response:\n                    raise AssertionError(\n                        f\"notify() call; notify_response={resp} \"\n                        f\"(expected {notify_response}) on {url}\")\n\n                if notify_response:\n                    # If we successfully got a response, there must have been\n                    # at least 1 target present\n                    assert targets > 0\n\n                # check that this doesn't change using different overflow\n                # methods\n                assert (\n                    obj.notify(\n                        body=self.body,\n                        title=self.title,\n                        notify_type=notify_type,\n                        overflow=OverflowMode.UPSTREAM,\n                    )\n                    == notify_response\n                )\n                assert (\n                    obj.notify(\n                        body=self.body,\n                        title=self.title,\n                        notify_type=notify_type,\n                        overflow=OverflowMode.TRUNCATE,\n                    )\n                    == notify_response\n                )\n                assert (\n                    obj.notify(\n                        body=self.body,\n                        title=self.title,\n                        notify_type=notify_type,\n                        overflow=OverflowMode.SPLIT,\n                    )\n                    == notify_response\n                )\n\n                #\n                # Handle varations of the Asset Object missing fields\n                #\n\n                # First make a backup\n                app_id = asset.app_id\n                app_desc = asset.app_desc\n\n                # now clear records\n                asset.app_id = None\n                asset.app_desc = None\n\n                # Notify should still work\n                assert (\n                    obj.notify(\n                        body=self.body,\n                        title=self.title,\n                        notify_type=notify_type,\n                    )\n                    == notify_response\n                )\n\n                # App ID only\n                asset.app_id = app_id\n                asset.app_desc = None\n\n                # Notify should still work\n                assert (\n                    obj.notify(\n                        body=self.body,\n                        title=self.title,\n                        notify_type=notify_type,\n                    )\n                    == notify_response\n                )\n\n                # App Desc only\n                asset.app_id = None\n                asset.app_desc = app_desc\n\n                if force_debug:\n                    # Enable access to areas otherwise locked away by the log\n                    # level\n                    original_is_enabled_for = obj.logger.isEnabledFor\n                    original_debug = obj.logger.debug\n                    try:\n                        # Force code paths guarded by isEnabledFor(DEBUG)\n                        obj.logger.isEnabledFor = mock.Mock(return_value=True)\n\n                        # Prevent actual logging emission (avoids pytest\n                        # capture closed stream)\n                        obj.logger.debug = mock.Mock()\n\n                        assert (\n                            obj.notify(\n                                body=self.body,\n                                title=self.title,\n                                notify_type=notify_type,\n                            )\n                            == notify_response\n                        )\n\n                    finally:\n                        # Restore\n                        obj.logger.isEnabledFor = original_is_enabled_for\n                        obj.logger.debug = original_debug\n                else:\n                    assert (\n                        obj.notify(\n                            body=self.body,\n                            title=self.title,\n                            notify_type=notify_type,\n                        )\n                        == notify_response\n                    )\n\n                # Restore\n                asset.app_id = app_id\n                asset.app_desc = app_desc\n\n                if check_attachments:\n                    # Test single attachment support; even if the service\n                    # doesn't support attachments, it should still\n                    # gracefully ignore the data\n                    attach = os.path.join(\n                        self.__test_var_dir, \"apprise-test.gif\"\n                    )\n                    assert (\n                        obj.notify(\n                            body=self.body,\n                            title=self.title,\n                            notify_type=notify_type,\n                            attach=attach,\n                        )\n                        == attach_response\n                    )\n\n                    # Same results should apply to a list of attachments\n                    attach = AppriseAttachment((\n                        os.path.join(self.__test_var_dir, \"apprise-test.gif\"),\n                        os.path.join(self.__test_var_dir, \"apprise-test.png\"),\n                        os.path.join(self.__test_var_dir, \"apprise-test.jpeg\"),\n                    ))\n\n                    assert (\n                        obj.notify(\n                            body=self.body,\n                            title=self.title,\n                            notify_type=notify_type,\n                            attach=attach,\n                        )\n                        == attach_response\n                    )\n\n                    if obj.attachment_support:\n                        #\n                        # Services that support attachments should support\n                        # sending a attachment (or more) without a body or\n                        # title specified:\n                        #\n                        assert (\n                            obj.notify(\n                                body=None,\n                                title=None,\n                                notify_type=notify_type,\n                                attach=attach,\n                            )\n                            == attach_response\n                        )\n\n                        # Turn off attachment support on the notifications\n                        # that support it so we can test that any logic we\n                        # have ot test against this flag is ran\n                        obj.attachment_support = False\n\n                        #\n                        # Notifications should still transmit as normal if\n                        # Attachment support is flipped off\n                        #\n                        assert (\n                            obj.notify(\n                                body=self.body,\n                                title=self.title,\n                                notify_type=notify_type,\n                                attach=attach,\n                            )\n                            == notify_response\n                        )\n\n                        #\n                        # We should not be able to send a message without a\n                        # body or title in this circumstance\n                        #\n                        assert (\n                            obj.notify(\n                                body=None,\n                                title=None,\n                                notify_type=notify_type,\n                                attach=attach,\n                            )\n                            is False\n                        )\n\n                        # Toggle Back\n                        obj.attachment_support = True\n\n                    else:  # No Attachment support\n                        #\n                        # We should not be able to send a message without a\n                        # body or title in this circumstance\n                        #\n                        assert (\n                            obj.notify(\n                                body=None,\n                                title=None,\n                                notify_type=notify_type,\n                                attach=attach,\n                            )\n                            is False\n                        )\n            else:\n\n                for exception in self.req_exceptions:\n                    mock_post.side_effect = exception\n                    mock_head.side_effect = exception\n                    mock_del.side_effect = exception\n                    mock_put.side_effect = exception\n                    mock_get.side_effect = exception\n                    mock_patch.side_effect = exception\n                    mock_request.side_effect = exception\n\n                    try:\n                        assert (\n                            obj.notify(\n                                body=self.body,\n                                title=self.title,\n                                notify_type=notify_type,\n                            )\n                            is False\n                        )\n\n                    except AssertionError:\n                        # Don't mess with these entries\n                        raise\n\n                    except Exception:\n                        # We can't handle this exception type\n                        raise\n\n        except AssertionError:\n            # Don't mess with these entries\n            raise\n\n        except Exception as e:\n            # Check that we were expecting this exception to happen\n            try:\n                if not isinstance(e, response):\n                    raise e\n\n            except TypeError:\n                raise e  # noqa: B904 - intentional re-raising in test helper\n\n        #\n        # Do the test again but without a title defined\n        #\n        try:\n            if test_requests_exceptions is False:\n                # check that we're as expected\n                assert (\n                    obj.notify(body=\"body\", notify_type=notify_type)\n                    == notify_response\n                )\n\n            else:\n                for exception in self.req_exceptions:\n                    mock_post.side_effect = exception\n                    mock_del.side_effect = exception\n                    mock_put.side_effect = exception\n                    mock_head.side_effect = exception\n                    mock_get.side_effect = exception\n                    mock_patch.side_effect = exception\n                    mock_request.side_effect = exception\n\n                    try:\n                        assert (\n                            obj.notify(body=self.body, notify_type=notify_type)\n                            is False\n                        )\n\n                    except AssertionError:\n                        # Don't mess with these entries\n                        raise\n\n                    except Exception:\n                        # We can't handle this exception type\n                        raise\n\n        except AssertionError:\n            # Don't mess with these entries\n            raise\n\n        except Exception as e:\n            # Check that we were expecting this exception to happen\n            if not isinstance(e, response):\n                raise e\n\n        return True\n"
  },
  {
    "path": "tests/test_api.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport asyncio\nimport concurrent.futures\nimport inspect\nfrom inspect import cleandoc\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom os.path import dirname, join\nimport re\nimport sys\nfrom typing import ClassVar\nfrom unittest import mock\n\nfrom helpers import OuterEventLoop\nimport pytest\nimport requests\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    AppriseAttachment,\n    NotificationManager,\n    NotifyBase,\n    NotifyFormat,\n    NotifyImageSize,\n    NotifyType,\n    PrivacyMode,\n    URLBase,\n    __version__,\n)\nfrom apprise.locale import LazyTranslation, gettext_lazy as _\nfrom apprise.plugins.base import RequirementsSpec\nfrom apprise.utils.parse import parse_list\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = join(dirname(__file__), \"var\")\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\ndef test_apprise_object():\n    \"\"\"\n    API: Apprise() object\n\n    \"\"\"\n\n    def do_notify(server, *args, **kwargs):\n        return server.notify(*args, **kwargs)\n\n    apprise_test(do_notify)\n\n\ndef test_apprise_async():\n    \"\"\"\n    API: Apprise() object asynchronous methods\n\n    \"\"\"\n    with OuterEventLoop() as loop:\n\n        def do_notify(server, *args, **kwargs):\n            return loop.run_until_complete(\n                server.async_notify(*args, **kwargs)\n            )\n\n        apprise_test(do_notify)\n\n\ndef apprise_test(do_notify):\n    a = Apprise()\n\n    # no items\n    assert len(a) == 0\n\n    # Apprise object can also be directly tested with 'if' keyword\n    # No entries results in a False response\n    assert not a\n\n    # Create an Asset object\n    asset = AppriseAsset(theme=\"default\")\n\n    # We can load the device using our asset\n    a = Apprise(asset=asset)\n\n    # We can load our servers up front as well\n    servers = [\n        \"json://myhost\",\n        \"kodi://kodi.server.local\",\n    ]\n\n    a = Apprise(servers=servers)\n\n    # 2 servers loaded\n    assert len(a) == 2\n\n    # Apprise object can also be directly tested with 'if' keyword\n    # At least one entry results in a True response\n    assert a\n\n    # We can retrieve our URLs this way:\n    assert len(a.urls()) == 2\n\n    # We can add another server\n    assert (\n        a.add(\n            \"mmosts://mattermost.server.local/3ccdd113474722377935511fc85d3dd4\"\n        )\n        is True\n    )\n    assert len(a) == 3\n\n    # Try adding nothing but delimiters\n    assert a.add(\",, ,, , , , ,\") is False\n\n    # The number of servers added doesn't change\n    assert len(a) == 3\n\n    # We can pop an object off of our stack by it's indexed value:\n    obj = a.pop(0)\n    assert isinstance(obj, NotifyBase) is True\n    assert len(a) == 2\n\n    # We can retrieve elements from our list too by reference:\n    assert isinstance(a[0].url(), str) is True\n\n    # We can iterate over our list too:\n    count = 0\n    for o in a:\n        assert isinstance(o.url(), str) is True\n        count += 1\n    # verify that we did indeed iterate over each element\n    assert len(a) == count\n\n    # We can empty our set\n    a.clear()\n    assert len(a) == 0\n\n    # An invalid schema\n    assert a.add(\"this is not a parseable url at all\") is False\n    assert len(a) == 0\n\n    # An unsupported schema\n    assert a.add(\"invalid://we.just.do.not.support.this.plugin.type\") is False\n    assert len(a) == 0\n\n    # A poorly formatted URL\n    assert a.add(\"json://user:@@@:bad?no.good\") is False\n    assert len(a) == 0\n\n    # Add a server with our asset we created earlier\n    assert (\n        a.add(\n            \"mmosts://mattermost.server.local/\"\n            \"3ccdd113474722377935511fc85d3dd4\",\n            asset=asset,\n        )\n        is True\n    )\n\n    # Clear our server listings again\n    a.clear()\n    assert len(a) == 0\n\n    # No servers to notify\n    assert do_notify(a, title=\"my title\", body=\"my body\") is False\n\n    # More Variations of Multiple Adding of URLs\n    a = Apprise()\n    assert a.add(servers)\n    assert len(a) == 2\n    a.clear()\n\n    assert a.add(\"ntfys://user:pass@host/test, json://localhost\")\n    assert len(a) == 2\n    a.clear()\n\n    assert a.add([\"ntfys://user:pass@host/test\", \"json://localhost\"])\n    assert len(a) == 2\n    a.clear()\n\n    assert a.add((\"ntfys://user:pass@host/test\", \"json://localhost\"))\n    assert len(a) == 2\n    a.clear()\n\n    assert a.add({\"ntfys://user:pass@host/test\", \"json://localhost\"})\n    assert len(a) == 2\n    a.clear()\n\n    # Pass a list entry containing 1 string with 2 elements in it\n    # Mimic Home-Assistant core-2024.2.1 Issue:\n    #   - https://github.com/home-assistant/core/issues/110242\n    #\n    # In this case, the first one will load, but not the second entry\n    # This is by design; but captured here to illustrate the issue\n    assert a.add([\"ntfys://user:pass@host/test, json://localhost\"])\n    assert len(a) == 1\n    assert a[0].url().startswith(\"ntfys://\")\n    a.clear()\n\n    # Following thorugh with the problem of providing a list containing\n    # an entry with 2 URLs in it... while the ntfys parsed okay above,\n    # the same can't be said for other combinations.  It's important\n    # to always keep strings separately\n    assert a.add([\"mailto://user:pass@example.com, json://localhost\"]) is False\n    assert len(a) == 0\n\n    # Showing that the URLs were valid on their own:\n    assert a.add(*[\"mailto://user:pass@example.com, json://localhost\"])\n    assert len(a) == 2\n    assert a[0].url().startswith(\"mailto://\")\n    assert a[1].url().startswith(\"json://\")\n    a.clear()\n\n    class BadNotification(NotifyBase):\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n            # We fail whenever we're initialized\n            raise TypeError()\n\n        @staticmethod\n        def parse_url(url, *args, **kwargs):\n            # always parseable\n            return NotifyBase.parse_url(url, verify_host=False)\n\n    class GoodNotification(NotifyBase):\n        def __init__(self, **kwargs):\n            super().__init__(notify_format=NotifyFormat.HTML, **kwargs)\n\n        def send(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        @staticmethod\n        def parse_url(url, *args, **kwargs):\n            # always parseable\n            return NotifyBase.parse_url(url, verify_host=False)\n\n    # Store our bad notification in our schema map\n    N_MGR[\"bad\"] = BadNotification\n\n    # Store our good notification in our schema map\n    N_MGR[\"good\"] = GoodNotification\n\n    # Just to explain what is happening here, we would have parsed the\n    # url properly but failed when we went to go and create an instance\n    # of it.\n    assert a.add(\"bad://localhost\") is False\n    assert len(a) == 0\n\n    # We'll fail because we've got nothing to notify\n    assert do_notify(a, title=\"my title\", body=\"my body\") is False\n\n    # Clear our server listings again\n    a.clear()\n\n    assert a.add(\"good://localhost\") is True\n    assert len(a) == 1\n\n    # Bad Notification Types are not accepted\n    assert (\n        do_notify(a, title=\"my title\", body=\"my body\", notify_type=\"bad\")\n        is False\n    )\n\n    # Notifications of string type are accepted\n    assert (\n        do_notify(a, title=\"my title\", body=\"my body\", notify_type=\"warning\")\n        is True\n    )\n\n    # Notifications of string type are accepted\n    assert (\n        do_notify(a, title=\"my title\", body=\"my body\", notify_type=\"warning\")\n        is True\n    )\n\n    # Notifications of string type are accepted\n    assert (\n        do_notify(a, title=\"my title\", body=\"my body\", notify_type=\"warning\")\n        is True\n    )\n\n    # Notifications where notify_type is of the NotifyType object is the\n    # preferred choice\n    assert (\n        do_notify(\n            a, title=\"my title\", body=\"my body\",\n            notify_type=NotifyType.WARNING)\n        is True\n    )\n\n    # No Title/Body combo's\n    assert do_notify(a, title=None, body=None) is False\n    assert do_notify(a, title=\"\", body=None) is False\n    assert do_notify(a, title=None, body=\"\") is False\n\n    assert do_notify(a, title=5, body=b\"bytes\") is False\n    assert do_notify(a, title=b\"bytes\", body=10) is False\n    assert do_notify(a, title=object(), body=b\"bytes\") is False\n    assert do_notify(a, title=b\"bytes\", body=object()) is False\n\n    # A Body must be present\n    assert do_notify(a, title=\"present\", body=None) is False\n\n    # Other combinations work fine\n    assert do_notify(a, title=None, body=\"present\") is True\n    assert do_notify(a, title=\"present\", body=\"present\") is True\n\n    # Send Attachment with success\n    attach = join(TEST_VAR_DIR, \"apprise-test.gif\")\n    assert (\n        do_notify(\n            a,\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Send the attachment as an AppriseAttachment object\n    assert (\n        do_notify(\n            a,\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=AppriseAttachment(attach),\n        )\n        is True\n    )\n\n    # test a invalid attachment\n    assert (\n        do_notify(\n            a,\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=\"invalid://\",\n        )\n        is False\n    )\n\n    # Repeat the same tests above...\n    # however do it by directly accessing the object; this grants the similar\n    # results:\n    assert (\n        do_notify(\n            a[0],\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Send the attachment as an AppriseAttachment object\n    assert (\n        do_notify(\n            a[0],\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=AppriseAttachment(attach),\n        )\n        is True\n    )\n\n    # test a invalid attachment\n    assert (\n        do_notify(\n            a[0],\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=\"invalid://\",\n        )\n        is False\n    )\n\n    class ThrowNotification(NotifyBase):\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            raise TypeError()\n\n        async def async_notify(self, **kwargs):\n            # Pretend everything is okay (async)\n            raise TypeError()\n\n    class RuntimeNotification(NotifyBase):\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            raise RuntimeError()\n\n        async def async_notify(self, **kwargs):\n            # Pretend everything is okay (async)\n            raise TypeError()\n\n    class FailNotification(NotifyBase):\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            return False\n\n        async def async_notify(self, **kwargs):\n            # Pretend everything is okay (async)\n            raise TypeError()\n\n    # Store our bad notification in our schema map\n    N_MGR[\"throw\"] = ThrowNotification\n\n    # Store our good notification in our schema map\n    N_MGR[\"fail\"] = FailNotification\n\n    # Store our good notification in our schema map\n    N_MGR[\"runtime\"] = RuntimeNotification\n\n    for async_mode in (True, False):\n        # Create an Asset object\n        asset = AppriseAsset(theme=\"default\", async_mode=async_mode)\n\n        # We can load the device using our asset\n        a = Apprise(asset=asset)\n\n        assert a.add(\"runtime://localhost\") is True\n        assert a.add(\"throw://localhost\") is True\n        assert a.add(\"fail://localhost\") is True\n        assert len(a) == 3\n\n        # Test when our notify both throws an exception and or just\n        # simply returns False\n        assert do_notify(a, title=\"present\", body=\"present\") is False\n\n    # Create a Notification that throws an unexected exception\n    class ThrowInstantiateNotification(NotifyBase):\n        def __init__(self, **kwargs):\n            # Pretend everything is okay\n            raise TypeError()\n\n    N_MGR.unload_modules()\n    N_MGR[\"throw\"] = ThrowInstantiateNotification\n\n    # Store our good notification in our schema map\n    N_MGR[\"good\"] = GoodNotification\n\n    # Reset our object\n    a.clear()\n    assert len(a) == 0\n\n    # Test our socket details\n    # rto = Socket Read Timeout\n    # cto = Socket Connect Timeout\n    plugin = a.instantiate(\"good://localhost?rto=5.1&cto=10\")\n    assert isinstance(plugin, NotifyBase)\n    assert plugin.socket_connect_timeout == pytest.approx(10.0)\n    assert plugin.socket_read_timeout == pytest.approx(5.1)\n\n    plugin = a.instantiate(\"good://localhost?rto=invalid&cto=invalid\")\n    assert isinstance(plugin, NotifyBase)\n    assert plugin.socket_connect_timeout == URLBase.socket_connect_timeout\n    assert plugin.socket_read_timeout == URLBase.socket_read_timeout\n\n    # Reset our object\n    a.clear()\n    assert len(a) == 0\n\n    with pytest.raises(ValueError):\n        # Encoding error\n        AppriseAsset(encoding=\"ascii\", storage_salt=\"ボールト\")\n\n    with pytest.raises(ValueError):\n        # Not a valid storage salt (must be str or bytes)\n        AppriseAsset(storage_salt=42)\n\n    # Set our cache to be off\n    plugin = a.instantiate(\"good://localhost?store=no\", asset=asset)\n    assert isinstance(plugin, NotifyBase)\n    assert plugin.url_id(lazy=False) is None\n    # Verify our cache is disabled\n    assert \"store=no\" in plugin.url()\n\n    with pytest.raises(ValueError):\n        # idlen must be greater then 0\n        AppriseAsset(storage_idlen=-1)\n\n    # Create a larger idlen\n    asset = AppriseAsset(storage_idlen=32)\n    plugin = a.instantiate(\"good://localhost\", asset=asset)\n    assert len(plugin.url_id()) == 32\n\n    # Instantiate a bad object\n    plugin = a.instantiate(object, tag=\"bad_object\")\n    assert plugin is None\n\n    # Instantiate a good object\n    plugin = a.instantiate(\"good://localhost\", tag=\"good\")\n    assert isinstance(plugin, NotifyBase)\n\n    # Test simple tagging inside of the object\n    assert \"good\" in plugin\n    assert \"bad\" not in plugin\n\n    # the in (__contains__ override) is based on or'ed content; so although\n    # 'bad' isn't tagged as being in the plugin, 'good' is, so the return\n    # value of this is True\n    assert [\"bad\", \"good\"] in plugin\n    assert {\"bad\", \"good\"} in plugin\n    assert (\"bad\", \"good\") in plugin\n\n    # We an add already substatiated instances into our Apprise object\n    a.add(plugin)\n    assert len(a) == 1\n\n    # We can add entries as a list too (to add more then one)\n    a.add([plugin, plugin, plugin])\n    assert len(a) == 4\n\n    # Reset our object again\n    a.clear()\n    with pytest.raises(TypeError):\n        a.instantiate(\"throw://localhost\", suppress_exceptions=False)\n\n    assert len(a) == 0\n\n    assert a.instantiate(\"throw://localhost\", suppress_exceptions=True) is None\n    assert len(a) == 0\n\n    #\n    # We rince and repeat the same tests as above, however we do them\n    # using the dict version\n    #\n\n    # Reset our object\n    a.clear()\n    assert len(a) == 0\n\n    # Instantiate a good object\n    plugin = a.instantiate({\"schema\": \"good\", \"host\": \"localhost\"}, tag=\"good\")\n    assert isinstance(plugin, NotifyBase)\n\n    # Test simple tagging inside of the object\n    assert \"good\" in plugin\n    assert \"bad\" not in plugin\n\n    # the in (__contains__ override) is based on or'ed content; so although\n    # 'bad' isn't tagged as being in the plugin, 'good' is, so the return\n    # value of this is True\n    assert [\"bad\", \"good\"] in plugin\n    assert {\"bad\", \"good\"} in plugin\n    assert (\"bad\", \"good\") in plugin\n\n    # We an add already substatiated instances into our Apprise object\n    a.add(plugin)\n    assert len(a) == 1\n\n    # We can add entries as a list too (to add more then one)\n    a.add([plugin, plugin, plugin])\n    assert len(a) == 4\n\n    # Reset our object again\n    a.clear()\n    with pytest.raises(TypeError):\n        a.instantiate(\n            {\"schema\": \"throw\", \"host\": \"localhost\"}, suppress_exceptions=False\n        )\n\n    assert len(a) == 0\n\n    assert (\n        a.instantiate(\n            {\"schema\": \"throw\", \"host\": \"localhost\"}, suppress_exceptions=True\n        )\n        is None\n    )\n    assert len(a) == 0\n\n\ndef test_apprise_pretty_print():\n    \"\"\"\n    API: Apprise() Pretty Print tests\n\n    \"\"\"\n    # Privacy Print\n    # PrivacyMode.Secret always returns the same thing to avoid guessing\n    assert (\n        URLBase.pprint(None, privacy=True, mode=PrivacyMode.Secret) == \"****\"\n    )\n    assert URLBase.pprint(42, privacy=True, mode=PrivacyMode.Secret) == \"****\"\n    assert (\n        URLBase.pprint(object, privacy=True, mode=PrivacyMode.Secret) == \"****\"\n    )\n    assert URLBase.pprint(\"\", privacy=True, mode=PrivacyMode.Secret) == \"****\"\n    assert URLBase.pprint(\"a\", privacy=True, mode=PrivacyMode.Secret) == \"****\"\n    assert (\n        URLBase.pprint(\"ab\", privacy=True, mode=PrivacyMode.Secret) == \"****\"\n    )\n    assert (\n        URLBase.pprint(\"abcdefghijk\", privacy=True, mode=PrivacyMode.Secret)\n        == \"****\"\n    )\n\n    # PrivacyMode.Outer\n    assert URLBase.pprint(None, privacy=True, mode=PrivacyMode.Outer) == \"\"\n    assert URLBase.pprint(42, privacy=True, mode=PrivacyMode.Outer) == \"\"\n    assert URLBase.pprint(object, privacy=True, mode=PrivacyMode.Outer) == \"\"\n    assert URLBase.pprint(\"\", privacy=True, mode=PrivacyMode.Outer) == \"\"\n    assert URLBase.pprint(\"a\", privacy=True, mode=PrivacyMode.Outer) == \"a...a\"\n    assert (\n        URLBase.pprint(\"ab\", privacy=True, mode=PrivacyMode.Outer) == \"a...b\"\n    )\n    assert (\n        URLBase.pprint(\"abcdefghijk\", privacy=True, mode=PrivacyMode.Outer)\n        == \"a...k\"\n    )\n\n    # PrivacyMode.Tail\n    assert URLBase.pprint(None, privacy=True, mode=PrivacyMode.Tail) == \"\"\n    assert URLBase.pprint(42, privacy=True, mode=PrivacyMode.Tail) == \"\"\n    assert URLBase.pprint(object, privacy=True, mode=PrivacyMode.Tail) == \"\"\n    assert URLBase.pprint(\"\", privacy=True, mode=PrivacyMode.Tail) == \"\"\n    assert URLBase.pprint(\"a\", privacy=True, mode=PrivacyMode.Tail) == \"...a\"\n    assert URLBase.pprint(\"ab\", privacy=True, mode=PrivacyMode.Tail) == \"...ab\"\n    assert (\n        URLBase.pprint(\"abcdefghijk\", privacy=True, mode=PrivacyMode.Tail)\n        == \"...hijk\"\n    )\n\n    # Quoting settings\n    assert URLBase.pprint(\" \", privacy=False, safe=\"\") == \"%20\"\n    assert URLBase.pprint(\" \", privacy=False, quote=False, safe=\"\") == \" \"\n\n\n@mock.patch(\"requests.request\")\ndef test_apprise_tagging(mock_request):\n    \"\"\"\n    API: Apprise() object tagging functionality\n\n    \"\"\"\n\n    def do_notify(server, *args, **kwargs):\n        return server.notify(*args, **kwargs)\n\n    apprise_tagging_test(mock_request, do_notify)\n\n\n@mock.patch(\"requests.request\")\ndef test_apprise_tagging_async(mock_request):\n    \"\"\"\n    API: Apprise() object tagging functionality asynchronous methods\n\n    \"\"\"\n    with OuterEventLoop() as loop:\n\n        def do_notify(server, *args, **kwargs):\n            return loop.run_until_complete(\n                server.async_notify(*args, **kwargs)\n            )\n\n        apprise_tagging_test(mock_request, do_notify)\n\n\ndef apprise_tagging_test(mock_request, do_notify):\n    # A request\n    robj = mock.Mock()\n    robj.raw = mock.Mock()\n    # Allow raw.read() calls\n    robj.raw.read.return_value = \"\"\n    robj.text = \"\"\n    robj.content = \"\"\n    mock_request.return_value = robj\n\n    # Simulate a successful notification\n    mock_request.return_value.status_code = requests.codes.ok\n\n    # Create our object\n    a = Apprise()\n\n    # An invalid addition can't add the tag\n    assert a.add(\"averyinvalidschema://localhost\", tag=\"uhoh\") is False\n    assert (\n        a.add(\n            {\"schema\": \"averyinvalidschema\", \"host\": \"localhost\"}, tag=\"uhoh\"\n        )\n        is False\n    )\n\n    # Add entry and assign it to a tag called 'awesome'\n    assert a.add(\"json://localhost/path1/\", tag=\"awesome\") is True\n    assert (\n        a.add(\n            {\"schema\": \"json\", \"host\": \"localhost\", \"fullpath\": \"/path1/\"},\n            tag=\"awesome\",\n        )\n        is True\n    )\n\n    # Add another notification and assign it to a tag called 'awesome'\n    # and another tag called 'local'\n    assert a.add(\"json://localhost/path2/\", tag=[\"mmost\", \"awesome\"]) is True\n\n    # notify the awesome tag; this would notify both services behind the\n    # scenes\n    assert (\n        do_notify(a, title=\"my title\", body=\"my body\", tag=\"awesome\") is True\n    )\n\n    # notify all of the tags\n    assert (\n        do_notify(\n            a, title=\"my title\", body=\"my body\", tag=[\"awesome\", \"mmost\"]\n        )\n        is True\n    )\n\n    # When we query against our loaded notifications for a tag that simply\n    # isn't assigned to anything, we return None.  None (different then False)\n    # tells us that we litterally had nothing to query.  We didn't fail...\n    # but we also didn't do anything...\n    assert (\n        do_notify(a, title=\"my title\", body=\"my body\", tag=\"missing\") is None\n    )\n\n    # Now to test the ability to and and/or notifications\n    a = Apprise()\n\n    # Add a tag by tuple\n    assert a.add(\"json://localhost/tagA/\", tag=(\"TagA\",)) is True\n    # Add 2 tags by string\n    assert a.add(\"json://localhost/tagAB/\", tag=\"TagA, TagB\") is True\n    # Add a tag using a set\n    assert a.add(\"json://localhost/tagB/\", tag={\"TagB\"}) is True\n    # Add a tag by string (again)\n    assert a.add(\"json://localhost/tagC/\", tag=\"TagC\") is True\n    # Add 2 tags using a list\n    assert a.add(\"json://localhost/tagCD/\", tag=[\"TagC\", \"TagD\"]) is True\n    # Add a tag by string (again)\n    assert a.add(\"json://localhost/tagD/\", tag=\"TagD\") is True\n    # add a tag set by set (again)\n    assert (\n        a.add(\"json://localhost/tagCDE/\", tag={\"TagC\", \"TagD\", \"TagE\"}) is True\n    )\n\n    # Expression: TagC and TagD\n    # Matches the following only:\n    #   - json://localhost/tagCD/\n    #   - json://localhost/tagCDE/\n    assert (\n        do_notify(a, title=\"my title\", body=\"my body\", tag=[(\"TagC\", \"TagD\")])\n        is True\n    )\n\n    # Expression: (TagY and TagZ) or TagX\n    # Matches nothing, None is returned in this case\n    assert (\n        do_notify(\n            a, title=\"my title\", body=\"my body\", tag=[(\"TagY\", \"TagZ\"), \"TagX\"]\n        )\n        is None\n    )\n\n    # Expression: (TagY and TagZ) or TagA\n    # Matches the following only:\n    #   - json://localhost/tagAB/\n    assert (\n        do_notify(\n            a, title=\"my title\", body=\"my body\", tag=[(\"TagY\", \"TagZ\"), \"TagA\"]\n        )\n        is True\n    )\n\n    # Expression: (TagE and TagD) or TagB\n    # Matches the following only:\n    #   - json://localhost/tagCDE/\n    #   - json://localhost/tagAB/\n    #   - json://localhost/tagB/\n    assert (\n        do_notify(\n            a, title=\"my title\", body=\"my body\", tag=[(\"TagE\", \"TagD\"), \"TagB\"]\n        )\n        is True\n    )\n\n    # Garbage Entries in tag field just get stripped out. the below\n    # is the same as notifying no tags at all. Since we have not added\n    # any entries that do not have tags (that we can match against)\n    # we fail.  None is returned as a way of letting us know that we\n    # had Notifications to notify, but since none of them matched our tag\n    # none were notified.\n    assert (\n        do_notify(\n            a,\n            title=\"my title\",\n            body=\"my body\",\n            tag=[\n                (object,),\n            ],\n        )\n        is None\n    )\n\n\ndef test_apprise_schemas():\n    \"\"\"\n    API: Apprise().schema() tests\n\n    \"\"\"\n    # Clear loaded modules\n    a = Apprise()\n\n    # no items\n    assert len(a) == 0\n\n    class TextNotification(NotifyBase):\n        # set our default notification format\n        notify_format = NotifyFormat.TEXT\n\n        # Garbage Protocol Entries\n        protocol = None\n\n        secure_protocol = (None, object)\n\n    class HtmlNotification(NotifyBase):\n\n        protocol = (\"html\", \"htm\")\n\n        secure_protocol = (\"htmls\", \"htms\")\n\n    class MarkDownNotification(NotifyBase):\n\n        protocol = \"markdown\"\n\n        secure_protocol = \"markdowns\"\n\n    schemas = URLBase.schemas(TextNotification)\n    assert isinstance(schemas, set) is True\n    # We didn't define a protocol or secure protocol\n    assert len(schemas) == 0\n\n    # Store our notifications into our schema map\n    N_MGR[\"text\"] = TextNotification\n    N_MGR[\"html\"] = HtmlNotification\n    N_MGR[\"markdown\"] = MarkDownNotification\n\n    schemas = URLBase.schemas(TextNotification)\n    assert isinstance(schemas, set) is True\n    # We didn't define a protocol or secure protocol one\n    # but one got assigned in he above N_MGR call\n    assert len(schemas) == 1\n    assert \"text\" in schemas\n\n    schemas = URLBase.schemas(HtmlNotification)\n    assert isinstance(schemas, set) is True\n    assert len(schemas) == 4\n    assert \"html\" in schemas\n    assert \"htm\" in schemas\n    assert \"htmls\" in schemas\n    assert \"htms\" in schemas\n\n    # Invalid entries do not disrupt schema calls\n    for garbage in (object(), None, 42):\n        schemas = URLBase.schemas(garbage)\n        assert isinstance(schemas, set) is True\n        assert len(schemas) == 0\n\n\ndef test_apprise_urlbase_object():\n    \"\"\"\n    API: Apprise() URLBase object testing\n\n    \"\"\"\n    results = URLBase.parse_url(\"https://localhost/path/?cto=3.0&verify=no\")\n    assert results.get(\"user\") is None\n    assert results.get(\"password\") is None\n    assert results.get(\"path\") == \"/path/\"\n    assert results.get(\"secure\") is True\n    assert results.get(\"verify\") is False\n    base = URLBase(**results)\n    assert base.request_timeout == (3.0, 4.0)\n    assert base.request_auth is None\n    assert base.request_url == \"https://localhost/path/\"\n    assert base.url().startswith(\"https://localhost/\")\n\n    results = URLBase.parse_url(\n        \"http://user:pass@localhost:34/path/here?rto=3.0&verify=yes\"\n    )\n    assert results.get(\"user\") == \"user\"\n    assert results.get(\"password\") == \"pass\"\n    assert results.get(\"fullpath\") == \"/path/here\"\n    assert results.get(\"secure\") is False\n    assert results.get(\"verify\") is True\n    base = URLBase(**results)\n    assert base.request_timeout == (4.0, 3.0)\n    assert base.request_auth == (\"user\", \"pass\")\n    assert base.request_url == \"http://localhost:34/path/here\"\n    assert base.url().startswith(\"http://user:pass@localhost:34/path/here\")\n\n    results = URLBase.parse_url(\"http://user@127.0.0.1/path/\")\n    assert results.get(\"user\") == \"user\"\n    assert results.get(\"password\") is None\n    assert results.get(\"fullpath\") == \"/path/\"\n    assert results.get(\"secure\") is False\n    assert results.get(\"verify\") is True\n    base = URLBase(**results)\n    assert base.request_timeout == (4.0, 4.0)\n    assert base.request_auth == (\"user\", None)\n    assert base.request_url == \"http://127.0.0.1/path/\"\n    assert base.url().startswith(\"http://user@127.0.0.1/path/\")\n\n    # Generic initialization\n    base = URLBase(**{\"schema\": \"\"})\n    assert base.request_timeout == (4.0, 4.0)\n    assert base.request_auth is None\n    assert base.request_url == \"http:///\"\n    assert base.url().startswith(\"http:///\")\n\n    base = URLBase()\n    assert base.request_timeout == (4.0, 4.0)\n    assert base.request_auth is None\n    assert base.request_url == \"http:///\"\n    assert base.url().startswith(\"http:///\")\n\n\ndef test_apprise_unique_id():\n    \"\"\"\n    API: Apprise() Input Formats tests\n\n    \"\"\"\n\n    # Default testing\n    obj1 = Apprise.instantiate(\"json://user@127.0.0.1/path\")\n    obj2 = Apprise.instantiate(\"json://user@127.0.0.1/path/?arg=\")\n\n    assert obj1.url_identifier == obj2.url_identifier\n    assert obj1.url_id() == obj2.url_id()\n    # Second call leverages lazy reference (so it's much faster\n    assert obj1.url_id() == obj2.url_id()\n    # Disable Lazy Setting\n    assert obj1.url_id(lazy=False) == obj2.url_id(lazy=False)\n\n    # A variation such as providing a password or altering the path makes the\n    # url_id() different:\n    obj2 = Apprise.instantiate(\"json://user@127.0.0.1/path2/?arg=\")  # path\n    assert obj1.url_id() != obj2.url_id()\n    obj2 = Apprise.instantiate(\n        \"jsons://user@127.0.0.1/path/?arg=\"\n    )  # secure flag\n    assert obj1.url_id() != obj2.url_id()\n    obj2 = Apprise.instantiate(\"json://user2@127.0.0.1/path/?arg=\")  # user\n    assert obj1.url_id() != obj2.url_id()\n    obj2 = Apprise.instantiate(\"json://user@127.0.0.1:8080/path/?arg=\")  # port\n    assert obj1.url_id() != obj2.url_id()\n    obj2 = Apprise.instantiate(\n        \"json://user:pass@127.0.0.1/path/?arg=\"\n    )  # password\n    assert obj1.url_id() != obj2.url_id()\n\n    # Leverage salt setting\n    asset = AppriseAsset(storage_salt=\"abcd\")\n\n    obj2 = Apprise.instantiate(\"json://user@127.0.0.1/path/\", asset=asset)\n    assert obj1.url_id(lazy=False) != obj2.url_id(lazy=False)\n\n    asset = AppriseAsset(storage_salt=b\"abcd\")\n    # same salt value produces a match again\n    obj1 = Apprise.instantiate(\"json://user@127.0.0.1/path/\", asset=asset)\n    assert obj1.url_id() == obj2.url_id()\n\n    # We'll add a good notification to our list\n    class TesNoURLID(NotifyBase):\n        \"\"\"This class is just sets a use case where we don't return a\n        url_identifier.\"\"\"\n\n        # we'll use this as a key to make our service easier to find\n        # in the next part of the testing\n        service_name = \"nourl\"\n\n        _url_identifier = False\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n        @staticmethod\n        def parse_url(url):\n            return NotifyBase.parse_url(url, verify_host=False)\n\n        @property\n        def url_identifier(self):\n            \"\"\"No URL Identifier.\"\"\"\n            return self._url_identifier\n\n    N_MGR[\"nourl\"] = TesNoURLID\n\n    # setting URL Identifier to False disables the generator\n    url = \"nourl://\"\n    obj = Apprise.instantiate(url)\n    # No generation takes place\n    assert obj.url_id() is None\n\n    #\n    # Dictionary Testing\n    #\n    obj._url_identifier = {\n        \"abc\": \"123\",\n        \"def\": b\"\\0\",\n        \"hij\": 42,\n        \"klm\": object,\n    }\n    # call uses cached value (from above)\n    assert obj.url_id() is None\n    # Tests dictionary key generation\n    assert obj.url_id(lazy=False) is not None\n\n    # List/Set/Tuple Testing\n    #\n    obj1 = Apprise.instantiate(url)\n    obj1._url_identifier = [\"123\", b\"\\0\", 42, object]\n    # Tests dictionary key generation\n    assert obj1.url_id() is not None\n\n    obj2 = Apprise.instantiate(url)\n    obj2._url_identifier = (\"123\", b\"\\0\", 42, object)\n    assert obj2.url_id() is not None\n    assert obj1.url_id() == obj2.url_id()\n\n    obj3 = Apprise.instantiate(url)\n    obj3._url_identifier = {\"123\", b\"\\0\", 42, object}\n    assert obj3.url_id() is not None\n\n    obj = Apprise.instantiate(url)\n    obj._url_identifier = b\"test\"\n    assert obj.url_id() is not None\n\n    obj = Apprise.instantiate(url)\n    obj._url_identifier = \"test\"\n    assert obj.url_id() is not None\n\n    # Testing Garbage\n    for x in (31, object, 43.1):\n        obj = Apprise.instantiate(url)\n        obj._url_identifier = x\n        assert obj.url_id() is not None\n\n\ndef test_apprise_notify_formats():\n    \"\"\"\n    API: Apprise() Input Formats tests\n\n    \"\"\"\n    # Need to set async_mode=False to call notify() instead of async_notify().\n    asset = AppriseAsset(async_mode=False)\n\n    a = Apprise(asset=asset)\n\n    # no items\n    assert len(a) == 0\n\n    class TextNotification(NotifyBase):\n        # set our default notification format\n        notify_format = NotifyFormat.TEXT\n\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    class HtmlNotification(NotifyBase):\n        # set our default notification format\n        notify_format = NotifyFormat.HTML\n\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    class MarkDownNotification(NotifyBase):\n        # set our default notification format\n        notify_format = NotifyFormat.MARKDOWN\n\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Store our notifications into our schema map\n    N_MGR[\"text\"] = TextNotification\n    N_MGR[\"html\"] = HtmlNotification\n    N_MGR[\"markdown\"] = MarkDownNotification\n\n    # Test Markdown; the above calls the markdown because our good://\n    # defined plugin above was defined to default to HTML which triggers\n    # a markdown to take place if the body_format specified on the notify\n    # call\n    assert a.add(\"html://localhost\") is True\n    assert a.add(\"html://another.server\") is True\n    assert a.add(\"html://and.another\") is True\n    assert a.add(\"text://localhost\") is True\n    assert a.add(\"text://another.server\") is True\n    assert a.add(\"text://and.another\") is True\n    assert a.add(\"markdown://localhost\") is True\n    assert a.add(\"markdown://another.server\") is True\n    assert a.add(\"markdown://and.another\") is True\n\n    assert len(a) == 9\n\n    assert (\n        a.notify(\n            title=\"markdown\",\n            body=\"## Testing Markdown\",\n            body_format=NotifyFormat.MARKDOWN,\n        )\n        is True\n    )\n\n    assert (\n        a.notify(\n            title=\"text\", body=\"Testing Text\", body_format=NotifyFormat.TEXT\n        )\n        is True\n    )\n\n    assert (\n        a.notify(\n            title=\"html\", body=\"<b>HTML</b>\", body_format=NotifyFormat.HTML\n        )\n        is True\n    )\n\n\ndef test_apprise_asset(tmpdir):\n    \"\"\"\n    API: AppriseAsset() object\n\n    \"\"\"\n    a = AppriseAsset(theme=\"light\")\n    # Default theme\n    assert a.theme == \"light\"\n\n    # Invalid kw handling\n    with pytest.raises(AttributeError):\n        AppriseAsset(invalid_kw=\"value\")\n\n    a = AppriseAsset(\n        theme=\"dark\",\n        image_path_mask=\"/{THEME}/{TYPE}-{XY}{EXTENSION}\",\n        image_url_mask=\"http://localhost/{THEME}/{TYPE}-{XY}{EXTENSION}\",\n    )\n\n    a.default_html_color = \"#abcabc\"\n\n    assert a.color(\"invalid\", tuple) == (171, 202, 188)\n    assert a.color(NotifyType.INFO, tuple) == (58, 163, 227)\n\n    assert a.color(\"invalid\", int) == 11258556\n    assert a.color(NotifyType.INFO, int) == 3843043\n\n    assert a.color(\"invalid\", None) == \"#abcabc\"\n    assert a.color(NotifyType.INFO, None) == \"#3AA3E3\"\n    # None is the default\n    assert a.color(NotifyType.INFO) == \"#3AA3E3\"\n\n    # Invalid Type\n    with pytest.raises(ValueError):\n        # The exception we expect since dict is not supported\n        a.color(NotifyType.INFO, dict)\n\n    # Test our ASCII mappings\n    assert a.ascii(\"invalid\") == \"[?]\"\n    assert a.ascii(NotifyType.INFO) == \"[i]\"\n    assert a.ascii(NotifyType.SUCCESS) == \"[+]\"\n    assert a.ascii(NotifyType.WARNING) == \"[~]\"\n    assert a.ascii(NotifyType.FAILURE) == \"[!]\"\n\n    # Invalid Type\n    with pytest.raises(ValueError):\n        # The exception we expect since dict is not supported\n        a.color(NotifyType.INFO, dict)\n\n    assert (\n        a.image_url(NotifyType.INFO, NotifyImageSize.XY_128)\n        == \"http://localhost/dark/info-128x128.png\"\n    )\n\n    # Default image size\n    assert (\n        a.image_url(NotifyType.INFO)\n        == \"http://localhost/dark/info-256x256.png\"\n    )\n\n    assert (\n        a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=False)\n        == \"/dark/info-256x256.png\"\n    )\n\n    # This path doesn't exist so image_raw will fail (since we just\n    # randompyl picked it for testing)\n    assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None\n\n    assert (\n        a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True)\n        is None\n    )\n\n    # Create a new object (with our default settings)\n    a = AppriseAsset()\n\n    # Our default configuration can access our file\n    assert (\n        a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True)\n        is not None\n    )\n\n    assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None\n\n    # Create a temporary directory\n    sub = tmpdir.mkdir(\"great.theme\")\n\n    # Write a file\n    sub.join(\n        f\"{NotifyType.INFO.value}-\"\n        f\"{NotifyImageSize.XY_256.value}.png\").write(\n            \"the content doesn't matter for testing.\"\n    )\n\n    # Create an asset that will reference our file we just created\n    a = AppriseAsset(\n        theme=\"great.theme\",\n        image_path_mask=(\n            f\"{dirname(sub.strpath)}/{{THEME}}/{{TYPE}}-{{XY}}.png\"\n        ),\n    )\n\n    # We'll be able to read file we just created\n    assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None\n\n    # We can retrieve the filename at this point even with must_exist set\n    # to True\n    assert (\n        a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True)\n        is not None\n    )\n\n    # Test case where we can't access the image file\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None\n\n    # Our content is retrivable again\n    assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None\n\n    # Disable all image references\n    a = AppriseAsset(image_path_mask=False, image_url_mask=False)\n\n    # We always return none in these calls now\n    assert a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None\n    assert a.image_url(NotifyType.INFO, NotifyImageSize.XY_256) is None\n    assert (\n        a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=False)\n        is None\n    )\n    assert (\n        a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=True)\n        is None\n    )\n\n    # Test our default extension out\n    a = AppriseAsset(\n        image_path_mask=\"/{THEME}/{TYPE}-{XY}{EXTENSION}\",\n        image_url_mask=\"http://localhost/{THEME}/{TYPE}-{XY}{EXTENSION}\",\n        default_extension=\".jpeg\",\n    )\n    assert (\n        a.image_path(NotifyType.INFO, NotifyImageSize.XY_256, must_exist=False)\n        == \"/default/info-256x256.jpeg\"\n    )\n\n    assert (\n        a.image_url(NotifyType.INFO, NotifyImageSize.XY_256)\n        == \"http://localhost/default/info-256x256.jpeg\"\n    )\n\n    # extension support\n    assert (\n        a.image_path(\n            NotifyType.INFO,\n            NotifyImageSize.XY_128,\n            must_exist=False,\n            extension=\".ico\",\n        )\n        == \"/default/info-128x128.ico\"\n    )\n\n    assert (\n        a.image_url(NotifyType.INFO, NotifyImageSize.XY_256, extension=\".test\")\n        == \"http://localhost/default/info-256x256.test\"\n    )\n\n    a = AppriseAsset(plugin_paths=(\"/tmp\",))\n    assert a.plugin_paths == (\"/tmp\",)\n\n\ndef test_apprise_disabled_plugins():\n    \"\"\"\n    API: Apprise() Disabled Plugin States\n\n    \"\"\"\n    # Ensure there are no other drives loaded\n    N_MGR.unload_modules(disable_native=True)\n    assert len(N_MGR) == 0\n\n    class TestDisabled01Notification(NotifyBase):\n        \"\"\"This class is used to test a pre-disabled state.\"\"\"\n\n        # Just flat out disable our service\n        enabled = False\n\n        # we'll use this as a key to make our service easier to find\n        # in the next part of the testing\n        service_name = \"na01\"\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"na01\"] = TestDisabled01Notification\n\n    class TestDisabled02Notification(NotifyBase):\n        \"\"\"This class is used to test a post-disabled state.\"\"\"\n\n        # we'll use this as a key to make our service easier to find\n        # in the next part of the testing\n        service_name = \"na02\"\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n            # enable state changes **AFTER** we initialize\n            self.enabled = False\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"na02\"] = TestDisabled02Notification\n\n    # Create our Apprise instance\n    a = Apprise()\n\n    result = a.details(lang=\"ca-en\", show_disabled=True)\n    assert isinstance(result, dict)\n    assert \"schemas\" in result\n    assert len(result[\"schemas\"]) == 2\n\n    # our na01 is disabled right from the get-go\n    entry = next(\n        (x for x in result[\"schemas\"] if x[\"service_name\"] == \"na01\"), None\n    )\n    assert entry is not None\n    assert entry[\"enabled\"] is False\n\n    plugin = a.instantiate(\"na01://localhost\")\n    # Object is just flat out disabled... nothing is instatiated\n    assert plugin is None\n\n    # our na02 isn't however until it's initialized; as a result\n    # it get's returned in our result set\n    entry = next(\n        (x for x in result[\"schemas\"] if x[\"service_name\"] == \"na02\"), None\n    )\n    assert entry is not None\n    assert entry[\"enabled\"] is True\n\n    plugin = a.instantiate(\"na02://localhost\")\n    # Object isn't disabled until the __init__() call.  But this is still\n    # enough to not instantiate the object:\n    assert plugin is None\n\n    # If we choose to filter our disabled, we can't unfortunately filter those\n    # that go disabled after instantiation, but we do filter out any that are\n    # already known to not be enabled:\n    result = a.details(lang=\"ca-en\", show_disabled=False)\n    assert isinstance(result, dict)\n    assert \"schemas\" in result\n    assert len(result[\"schemas\"]) == 1\n\n    # We'll add a good notification to our list\n    class TesEnabled01Notification(NotifyBase):\n        \"\"\"This class is just a simple enabled one.\"\"\"\n\n        # we'll use this as a key to make our service easier to find\n        # in the next part of the testing\n        service_name = \"good\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"good\"] = TesEnabled01Notification\n\n    # The last thing we'll simulate is a case where the plugin is just\n    # disabled at a later time long into it's life.  this is just to allow\n    # administrators to stop the flow of their notifications for their own\n    # given reasons.\n    plugin = a.instantiate(\"good://localhost\")\n    assert isinstance(plugin, NotifyBase)\n\n    # we'll toggle our state\n    plugin.enabled = False\n\n    # As a result, we can now no longer send a notification:\n    assert plugin.notify(\"My Message\") is False\n\n    # As just a proof of how you can toggle the state back:\n    plugin.enabled = True\n\n    # our notifications will go okay now\n    assert plugin.notify(\"My Message\") is True\n\n    # Restore our modules\n    N_MGR.unload_modules()\n\n\ndef test_apprise_details():\n    \"\"\"\n    API: Apprise() Details\n\n    \"\"\"\n\n    # This is a made up class that is just used to verify\n    class TestDetailNotification(NotifyBase):\n        \"\"\"This class is used to test various configurations supported.\"\"\"\n\n        # Minimum requirements for a plugin to produce details\n        service_name = \"Detail Testing\"\n\n        # The default simple (insecure) protocol (used by NotifyMail)\n        protocol = \"details\"\n\n        # Set test_bool flag\n        always_true = True\n        always_false = False\n\n        # Define object templates\n        templates = (\n            \"{schema}://{host}\",\n            \"{schema}://{host}:{port}\",\n            \"{schema}://{user}@{host}:{port}\",\n            \"{schema}://{user}:{pass}@{host}:{port}\",\n        )\n\n        # Define our tokens; these are the minimum tokens required required to\n        # be passed into this function (as arguments). The syntax appends any\n        # previously defined in the base package and builds onto them\n        template_tokens = dict(\n            NotifyBase.template_tokens,\n            **{\n                \"notype\": {\n                    # name is a minimum requirement\n                    \"name\": _(\"no type\"),\n                },\n                \"regex_test01\": {\n                    \"name\": _(\"RegexTest\"),\n                    \"type\": \"string\",\n                    \"regex\": r\"^[A-Z0-9]$\",\n                },\n                \"regex_test02\": {\n                    \"name\": _(\"RegexTest\"),\n                    # Support regex options too\n                    \"regex\": (r\"^[A-Z0-9]$\", \"i\"),\n                },\n                \"regex_test03\": {\n                    \"name\": _(\"RegexTest\"),\n                    # Support regex option without a second option\n                    \"regex\": r\"^[A-Z0-9]$\",\n                },\n                \"regex_test04\": {\n                    # this entry would just end up getting removed\n                    \"regex\": None,\n                },\n                # List without delimiters (causes defaults to kick in)\n                \"mylistA\": {\n                    \"name\": \"fruit\",\n                    \"type\": \"list:string\",\n                },\n                # A list with a delimiter list\n                \"mylistB\": {\n                    \"name\": \"softdrinks\",\n                    \"type\": \"list:string\",\n                    \"delim\": [\"|\", \"-\"],\n                },\n            },\n        )\n\n        template_args = dict(\n            NotifyBase.template_args,\n            **{\n                # Test _exist_if logic\n                \"test_exists_if_01\": {\n                    \"name\": \"Always False\",\n                    \"type\": \"bool\",\n                    # Provide a default\n                    \"default\": False,\n                    # Base the existance of this key/value entry on the lookup\n                    # of this class value at runtime. Hence:\n                    #     if not NotifyObject.always_false\n                    #         del this_entry\n                    #\n                    \"_exists_if\": \"always_false\",\n                },\n                # Test _exist_if logic\n                \"test_exists_if_02\": {\n                    \"name\": \"Always True\",\n                    \"type\": \"bool\",\n                    # Provide a default\n                    \"default\": False,\n                    # Base the existance of this key/value entry on the lookup\n                    # of this class value at runtime. Hence:\n                    #     if not NotifyObject.always_true\n                    #         del this_entry\n                    #\n                    \"_exists_if\": \"always_true\",\n                },\n                # alias_of testing\n                \"test_alias_of\": {\"alias_of\": \"mylistB\", \"delim\": (\"-\", \" \")},\n            },\n        )\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    # Store our good detail notification in our schema map\n    N_MGR[\"details\"] = TestDetailNotification\n\n    # This is a made up class that is just used to verify\n    class TestReq01Notification(NotifyBase):\n        \"\"\"This class is used to test various requirement configurations.\"\"\"\n\n        # Set some requirements\n        requirements: ClassVar[RequirementsSpec] = {\n            \"packages_required\": [\n                \"cryptography <= 3.4\",\n                \"ultrasync\",\n            ],\n            \"packages_recommended\": \"django\",\n        }\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req01\"] = TestReq01Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq02Notification(NotifyBase):\n        \"\"\"This class is used to test various requirement configurations.\"\"\"\n\n        # Just not enabled at all\n        enabled = False\n\n        # Set some requirements\n        requirements: ClassVar[RequirementsSpec] = {\n            # None and/or [] is implied, but jsut to show that the code won't\n            # crash if explicitly set this way:\n            \"packages_required\": None,\n            \"packages_recommended\": [\n                \"cryptography <= 3.4\",\n            ],\n        }\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req02\"] = TestReq02Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq03Notification(NotifyBase):\n        \"\"\"This class is used to test various requirement configurations.\"\"\"\n\n        # Set some requirements\n        requirements: ClassVar[RequirementsSpec] = {\n            # We can over-ride the default details assigned to our plugin if\n            # specified\n            \"details\": _(\"some specified requirement details\"),\n            # We can set a string value as well (it does not have to be a list)\n            \"packages_recommended\": \"cryptography <= 3.4\",\n        }\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req03\"] = TestReq03Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq04Notification(NotifyBase):\n        \"\"\"This class is used to test a case where our requirements is fixed to\n        a None.\"\"\"\n\n        # This is the same as saying there are no requirements\n        requirements = None\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req04\"] = TestReq04Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq05Notification(NotifyBase):\n        \"\"\"This class is used to test a case where only packages_recommended is\n        identified.\"\"\"\n\n        requirements: ClassVar[RequirementsSpec] = {\n            # We can set a string value as well (it does not have to be a list)\n            \"packages_recommended\": \"cryptography <= 3.4\"\n        }\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req05\"] = TestReq05Notification\n\n    # Create our Apprise instance\n    a = Apprise()\n\n    # Dictionary response\n    result = a.details()\n    assert isinstance(result, dict)\n\n    # Test different variations of our call\n    result = a.details(lang=\"ca-fr\")\n    assert isinstance(result, dict)\n    for entry in result[\"schemas\"]:\n        # Verify our key does not exist because we did not ask for it\n        assert \"enabled\" not in entry\n        assert \"requirements\" not in entry\n\n    result = a.details(lang=\"us-en\", show_requirements=True)\n    assert isinstance(result, dict)\n    for entry in result[\"schemas\"]:\n        # Verify our key does not exist because we did not ask for it\n        assert \"enabled\" not in entry\n\n        # Requirements are set for display\n        assert \"requirements\" in entry\n        assert \"details\" in entry[\"requirements\"]\n        assert \"packages_required\" in entry[\"requirements\"]\n        assert \"packages_recommended\" in entry[\"requirements\"]\n        assert isinstance(\n            entry[\"requirements\"][\"details\"], (str, LazyTranslation)\n        )\n        assert isinstance(entry[\"requirements\"][\"packages_required\"], list)\n        assert isinstance(entry[\"requirements\"][\"packages_recommended\"], list)\n\n    result = a.details(lang=\"ca-en\", show_disabled=True)\n    assert isinstance(result, dict)\n    for entry in result[\"schemas\"]:\n        # Verify that our plugin state is available to us\n        assert \"enabled\" in entry\n        assert isinstance(entry[\"enabled\"], bool)\n\n        # Verify our key does not exist because we did not ask for it\n        assert \"requirements\" not in entry\n\n    result = a.details(\n        lang=\"ca-fr\", show_requirements=True, show_disabled=True\n    )\n    assert isinstance(result, dict)\n    for entry in result[\"schemas\"]:\n        # Plugin States are set for display\n        assert \"enabled\" in entry\n        assert isinstance(entry[\"enabled\"], bool)\n\n        # Requirements are set for display\n        assert \"requirements\" in entry\n        assert \"details\" in entry[\"requirements\"]\n        assert \"packages_required\" in entry[\"requirements\"]\n        assert \"packages_recommended\" in entry[\"requirements\"]\n        assert isinstance(\n            entry[\"requirements\"][\"details\"], (str, LazyTranslation)\n        )\n        assert isinstance(entry[\"requirements\"][\"packages_required\"], list)\n        assert isinstance(entry[\"requirements\"][\"packages_recommended\"], list)\n\n\ndef test_apprise_details_plugin_verification():\n    \"\"\"\n    API: Apprise() Details Plugin Verification\n\n    \"\"\"\n    # Prepare our object\n    a = Apprise()\n\n    # Details object\n    details = a.details()\n\n    # Dictionary response\n    assert isinstance(details, dict)\n\n    # Details object with language defined:\n    details = a.details(lang=\"en\")\n\n    # Dictionary response\n    assert isinstance(details, dict)\n\n    # Details object with unsupported language:\n    details = a.details(lang=\"xx\")\n\n    # Dictionary response\n    assert isinstance(details, dict)\n\n    # Apprise version\n    assert \"version\" in details\n    assert details.get(\"version\") == __version__\n\n    # Defined schemas identify each plugin\n    assert \"schemas\" in details\n    assert isinstance(details.get(\"schemas\"), list)\n\n    # We have an entry per defined plugin\n    assert \"asset\" in details\n    assert isinstance(details.get(\"asset\"), dict)\n    assert \"app_id\" in details[\"asset\"]\n    assert \"app_desc\" in details[\"asset\"]\n    assert \"default_extension\" in details[\"asset\"]\n    assert \"theme\" in details[\"asset\"]\n    assert \"image_path_mask\" in details[\"asset\"]\n    assert \"image_url_mask\" in details[\"asset\"]\n    assert \"image_url_logo\" in details[\"asset\"]\n\n    # Valid Type Regular Expression Checker\n    # Case Sensitive and MUST match the following:\n    is_valid_type_re = re.compile(r\"((choice|list):)?(string|bool|int|float)\")\n\n    # match tokens found in templates so we can cross reference them back\n    # to see if they have a matching argument\n    template_token_re = re.compile(r\"{([^}]+)}[^{]*?(?=$|{)\")\n\n    # Define acceptable map_to arguments that can be tied in with the\n    # kwargs function definitions.\n    valid_kwargs = {\n        # General Parameters\n        \"user\",\n        \"password\",\n        \"port\",\n        \"host\",\n        \"schema\",\n        \"fullpath\",\n        # NotifyBase parameters:\n        \"tz\",\n        \"format\",\n        \"overflow\",\n        \"emojis\",\n        # URLBase parameters:\n        \"verify\",\n        \"cto\",\n        \"rto\",\n        \"store\",\n    }\n\n    # Valid Schema Entries:\n    valid_schema_keys = (\n        \"name\",\n        \"private\",\n        \"required\",\n        \"type\",\n        \"values\",\n        \"min\",\n        \"max\",\n        \"regex\",\n        \"default\",\n        \"list\",\n        \"delim\",\n        \"prefix\",\n        \"map_to\",\n        \"alias_of\",\n        \"group\",\n    )\n    for entry in details[\"schemas\"]:\n\n        # Track the map_to entries (if specified); We need to make sure that\n        # these properly map back\n        map_to_entries = set()\n\n        # Track the alias_of entries\n        map_to_aliases = set()\n\n        # A Service Name MUST be defined\n        assert \"service_name\" in entry\n        assert isinstance(entry[\"service_name\"], (str, LazyTranslation))\n\n        # Acquire our protocols\n        protocols = parse_list(entry[\"protocols\"], entry[\"secure_protocols\"])\n\n        # At least one schema/protocol MUST be defined\n        assert len(protocols) > 0\n\n        # our details\n        assert \"details\" in entry\n        assert isinstance(entry[\"details\"], dict)\n\n        # All schema details should include args\n        for section in [\"kwargs\", \"args\", \"tokens\"]:\n            assert section in entry[\"details\"]\n            assert isinstance(entry[\"details\"][section], dict)\n\n            for key, arg in entry[\"details\"][section].items():\n                # Validate keys (case-sensitive)\n                assert len([k for k in arg if k not in valid_schema_keys]) == 0\n\n                # Test our argument\n                assert isinstance(arg, dict)\n\n                if \"alias_of\" not in arg:\n                    # Minimum requirement of an argument\n                    assert \"name\" in arg\n                    assert isinstance(arg[\"name\"], str)\n\n                    assert \"type\" in arg\n                    assert isinstance(arg[\"type\"], str)\n                    assert is_valid_type_re.match(arg[\"type\"]) is not None\n\n                    if \"min\" in arg:\n                        assert arg[\"type\"].endswith(\"float\") or arg[\n                            \"type\"\n                        ].endswith(\"int\")\n                        assert isinstance(arg[\"min\"], (int, float))\n\n                        if \"max\" in arg:\n                            # If a min and max was specified, at least check\n                            # to confirm the min is less then the max\n                            assert arg[\"min\"] < arg[\"max\"]\n\n                    if \"max\" in arg:\n                        assert arg[\"type\"].endswith(\"float\") or arg[\n                            \"type\"\n                        ].endswith(\"int\")\n                        assert isinstance(arg[\"max\"], (int, float))\n\n                    if \"private\" in arg:\n                        assert isinstance(arg[\"private\"], bool)\n\n                    if \"required\" in arg:\n                        assert isinstance(arg[\"required\"], bool)\n\n                    if \"prefix\" in arg:\n                        assert isinstance(arg[\"prefix\"], str)\n                        if section == \"kwargs\":\n                            # The only acceptable prefix types for kwargs\n                            assert arg[\"prefix\"] in (\":\", \"+\", \"-\")\n\n                    else:\n                        # kwargs requires that the 'prefix' is defined\n                        assert section != \"kwargs\"\n\n                    if \"map_to\" in arg:\n                        # must be a string\n                        assert isinstance(arg[\"map_to\"], str)\n                        # Track our map_to object\n                        map_to_entries.add(arg[\"map_to\"])\n\n                    else:\n                        map_to_entries.add(key)\n\n                    # Some verification\n                    if arg[\"type\"].startswith(\"choice\"):\n\n                        # choice:bool is redundant and should be swapped to\n                        # just bool\n                        assert not arg[\"type\"].endswith(\"bool\")\n\n                        # Choices require that a values list is provided\n                        assert \"values\" in arg\n                        assert isinstance(\n                            arg[\"values\"], (list, tuple, frozenset, set))\n                        assert len(arg[\"values\"]) > 0\n\n                        # Test default\n                        if \"default\" in arg:\n                            # if a default is provided on a choice object,\n                            # it better be in the list of values\n                            assert arg[\"default\"] in arg[\"values\"]\n\n                    if arg[\"type\"].startswith(\"bool\"):\n                        # Boolean choices are less restrictive but require a\n                        # default value\n                        assert \"default\" in arg\n                        assert isinstance(arg[\"default\"], bool)\n\n                    if \"regex\" in arg:\n                        # Regex must ALWAYS be in the format (regex, option)\n                        assert isinstance(arg[\"regex\"], (tuple, list))\n                        assert len(arg[\"regex\"]) == 2\n                        assert isinstance(arg[\"regex\"][0], str)\n                        assert arg[\"regex\"][1] is None or isinstance(\n                            arg[\"regex\"][1], str\n                        )\n\n                        # Compile the regular expression to verify that it is\n                        # valid\n                        try:\n                            re.compile(arg[\"regex\"][0])\n\n                        except:\n                            assert \"{} is an invalid regex\".format(\n                                arg[\"regex\"][0]\n                            )\n\n                        # Regex should always start and/or end with ^/$\n                        assert (\n                            re.match(r\"^\\^.+?$\", arg[\"regex\"][0]) is not None\n                        )\n                        assert (\n                            re.match(r\"^.+?\\$$\", arg[\"regex\"][0]) is not None\n                        )\n\n                    if arg[\"type\"].startswith(\"list\"):\n                        # Delimiters MUST be defined\n                        assert \"delim\" in arg\n                        assert isinstance(arg[\"delim\"], (list, tuple))\n                        assert len(arg[\"delim\"]) > 0\n\n                else:  # alias_of is in the object\n                    # Ensure we're not already in the tokens section\n                    # The alias_of object has no value here\n                    assert section != \"tokens\"\n\n                    # must be a string\n                    assert isinstance(arg[\"alias_of\"], (str, list, tuple, set))\n\n                    aliases = (\n                        [arg[\"alias_of\"]]\n                        if isinstance(arg[\"alias_of\"], str)\n                        else arg[\"alias_of\"]\n                    )\n\n                    for alias_of in aliases:\n                        # Track our alias_of object\n                        map_to_aliases.add(alias_of)\n\n                        # We can't be an alias_of ourselves\n                        if key == alias_of:\n                            # This is acceptable as long as we exist in the\n                            # tokens table because that is truely what we map\n                            # back to\n                            assert key in entry[\"details\"][\"tokens\"]\n\n                        else:\n                            # Throw the problem into an assert tag for\n                            # debugging purposes... the mapping is not\n                            # acceptable\n                            assert key != alias_of\n\n                        # alias_of always references back to tokens\n                        assert (\n                            alias_of in entry[\"details\"][\"tokens\"]\n                            or alias_of in entry[\"details\"][\"args\"]\n                        )\n\n                        # Find a list directive in our tokens\n                        t_match = (\n                            entry[\"details\"][\"tokens\"]\n                            .get(alias_of, {})\n                            .get(\"type\", \"\")\n                            .startswith(\"list\")\n                        )\n\n                        a_match = (\n                            entry[\"details\"][\"args\"]\n                            .get(alias_of, {})\n                            .get(\"type\", \"\")\n                            .startswith(\"list\")\n                        )\n\n                        if not (t_match or a_match):\n                            # Ensure the only token we have is the alias_of\n                            # hence record should look like as example):\n                            # {\n                            #    'token': {\n                            #      'alias_of': 'apitoken',\n                            #    },\n                            # }\n                            #\n                            # Or if it can represent more then one entry; in\n                            # this case, one must define a name (to define\n                            # grouping).\n                            # {\n                            #    'token': {\n                            #      'name': 'Tokens',\n                            #      'alias_of': ('apitoken', 'webtoken'),\n                            #    },\n                            # }\n                            if isinstance(arg[\"alias_of\"], str):\n                                assert len(entry[\"details\"][section][key]) == 1\n                            else:  # is tuple,list, or set\n                                assert len(entry[\"details\"][section][key]) == 2\n                                # Must have a name defined to define grouping\n                                assert \"name\" in entry[\"details\"][section][key]\n\n                        else:\n                            # We're a list, we allow up to 2 variables\n                            # Obviously we have the alias_of entry; that's why\n                            # were at this part of the code.  But we can\n                            # additionally provide a 'delim' over-ride.\n                            assert len(entry[\"details\"][section][key]) <= 2\n                            if len(entry[\"details\"][section][key]) == 2:\n                                # Verify that it is in fact the 'delim' tag\n                                assert (\n                                    \"delim\" in entry[\"details\"][section][key]\n                                )\n                                # If we do have a delim value set, it must be\n                                # of a list/set/tuple type\n                                assert isinstance(\n                                    entry[\"details\"][section][key][\"delim\"],\n                                    (tuple, set, list),\n                                )\n\n        spec = inspect.getfullargspec(N_MGR._schema_map[protocols[0]].__init__)\n\n        function_args = (\n            (set(parse_list(spec.varkw)) - {\"kwargs\"})\n            | (set(spec.args) - {\"self\"})\n            | valid_kwargs\n        )\n\n        # Iterate over our map_to_entries and make sure that everything\n        # maps to a function argument\n        for arg in map_to_entries:\n            if arg not in function_args:\n                # This print statement just makes the error easier to\n                # troubleshoot\n                raise AssertionError(\n                    \"{}.__init__() expects a {}=None entry according to \"\n                    \"template configuration\".format(\n                        N_MGR._schema_map[protocols[0]].__name__, arg\n                    )\n                )\n\n        # Iterate over all of the function arguments and make sure that\n        # it maps back to a key\n        function_args -= valid_kwargs\n        for arg in function_args:\n            if arg not in map_to_entries:\n                err = N_MGR._schema_map[protocols[0]].__name__.__init__(arg)\n                raise AssertionError(\n                    f\"{err} found but not defined in the template \"\n                    \"configuration\"\n                )\n\n        # Iterate over our map_to_aliases and make sure they were defined in\n        # either the as a token or arg\n        for arg in map_to_aliases:\n            assert arg in set(entry[\"details\"][\"args\"].keys()) | set(\n                entry[\"details\"][\"tokens\"].keys()\n            )\n\n        # Template verification\n        assert \"templates\" in entry[\"details\"]\n        assert isinstance(entry[\"details\"][\"templates\"], (set, tuple, list))\n\n        # Iterate over our templates and parse our arguments\n        for template in entry[\"details\"][\"templates\"]:\n            # Ensure we've properly opened and closed all of our tokens\n            assert template.count(\"{\") == template.count(\"}\")\n\n            expected_tokens = template.count(\"}\")\n            args = template_token_re.findall(template)\n            assert expected_tokens == len(args)\n\n            # Build a cross reference set of our current defined objects\n            defined_tokens = set()\n            for key, arg in entry[\"details\"][\"tokens\"].items():\n                defined_tokens.add(key)\n                if \"alias_of\" in arg:\n                    defined_tokens.add(arg[\"alias_of\"])\n\n            # We want to make sure all of our defined tokens have been\n            # accounted for in at least one defined template\n            for arg in args:\n                assert arg in set(entry[\"details\"][\"args\"].keys()) | set(\n                    entry[\"details\"][\"tokens\"].keys()\n                )\n\n                # The reverse of the above; make sure that each entry defined\n                # in the template_tokens is accounted for in at least one of\n                # the defined templates\n                assert arg in defined_tokens\n\n\n@mock.patch(\"requests.request\")\n@mock.patch(\"asyncio.gather\", wraps=asyncio.gather)\n@mock.patch(\n    \"concurrent.futures.ThreadPoolExecutor\",\n    wraps=concurrent.futures.ThreadPoolExecutor,\n)\ndef test_apprise_async_mode(mock_threadpool, mock_gather, mock_request):\n    \"\"\"\n    API: Apprise() async_mode tests\n\n    \"\"\"\n    mock_request.return_value.status_code = requests.codes.ok\n\n    # Define some servers\n    servers = [\n        \"xml://localhost\",\n        \"json://localhost\",\n    ]\n\n    # Default Async Mode is to be enabled\n    asset = AppriseAsset()\n    assert asset.async_mode is True\n\n    # Load our asset\n    a = Apprise(asset=asset)\n\n    # add our servers\n    a.add(servers=servers)\n\n    # 2 servers loaded\n    assert len(a) == 2\n\n    # Our servers should carry this flag\n    for server in a:\n        assert server.asset.async_mode is True\n\n    # Send Notifications Asyncronously\n    assert a.notify(\"async\") is True\n\n    # Verify our thread pool was created\n    assert mock_threadpool.call_count == 1\n    mock_threadpool.reset_mock()\n\n    # Provide an over-ride now\n    asset = AppriseAsset(async_mode=False)\n    assert asset.async_mode is False\n\n    # Load our asset\n    a = Apprise(asset=asset)\n\n    # Verify our configuration kept\n    assert a.asset.async_mode is False\n\n    # add our servers\n    a.add(servers=servers)\n\n    # 2 servers loaded\n    assert len(a) == 2\n\n    # Our servers should carry this flag\n    for server in a:\n        assert server.asset.async_mode is False\n\n    # Send Notifications Syncronously\n    assert a.notify(\"sync\") is True\n    # Sequential send doesn't require a gather\n    assert mock_gather.call_count == 0\n    mock_gather.reset_mock()\n\n    # another way of looking a our false set asset configuration\n    assert a[0].asset.async_mode is False\n    assert a[1].asset.async_mode is False\n\n    # Adjust 1 of the servers async_mode settings\n    a[0].asset.async_mode = True\n    assert a[0].asset.async_mode is True\n\n    # They all share the same object, so this gets toggled too\n    assert a[1].asset.async_mode is True\n\n    # We'll just change this one\n    a[1].asset = AppriseAsset(async_mode=False)\n    assert a[0].asset.async_mode is True\n    assert a[1].asset.async_mode is False\n\n    # Send 1 Notification Syncronously, the other Asyncronously\n    assert a.notify(\"a mixed batch\") is True\n\n    # Verify we didn't use a thread pool for a single notification\n    assert mock_threadpool.call_count == 0\n    mock_threadpool.reset_mock()\n\n\ndef test_notify_matrix_dynamic_importing(tmpdir):\n    \"\"\"\n    API: Apprise() Notify Matrix Importing\n\n    \"\"\"\n\n    # Make our new path valid\n    suite = tmpdir.mkdir(\"apprise_notify_test_suite\")\n    suite.join(\"__init__.py\").write(\"\")\n\n    module_name = \"badnotify\"\n\n    # Update our path to point to our new test suite\n    sys.path.insert(0, str(suite))\n\n    # Create a base area to work within\n    base = suite.mkdir(module_name)\n    base.join(\"__init__.py\").write(\"\")\n\n    # Test no app_id\n    base.join(\"NotifyBadFile1.py\").write(cleandoc(\"\"\"\n        class NotifyBadFile1:\n            pass\n        \"\"\"))\n\n    # No class of the same name\n    base.join(\"NotifyBadFile2.py\").write(cleandoc(\"\"\"\n        class BadClassName:\n            pass\n        \"\"\"))\n\n    # Exception thrown\n    base.join(\"NotifyBadFile3.py\").write(\"\"\"raise ImportError()\"\"\")\n\n    # Utilizes a schema:// already occupied (as string)\n    base.join(\"NotifyGoober.py\").write(cleandoc(\"\"\"\n        from apprise import NotifyBase\n        class NotifyGoober(NotifyBase):\n            # This class tests the fact we have a new class name, but we're\n            # trying to over-ride items previously used\n\n            # The default simple (insecure) protocol (used by NotifyMail)\n            protocol = ('mailto', 'goober')\n\n            # The default secure protocol (used by NotifyMail)\n            secure_protocol = 'mailtos'\n\n            @staticmethod\n            def parse_url(url, *args, **kwargs):\n                # always parseable\n                return ConfigBase.parse_url(url, verify_host=False)\n        \"\"\"))\n\n    # Utilizes a schema:// already occupied (as tuple)\n    base.join(\"NotifyBugger.py\").write(cleandoc(\"\"\"\n        from apprise import NotifyBase\n        class NotifyBugger(NotifyBase):\n            # This class tests the fact we have a new class name, but we're\n            # trying to over-ride items previously used\n\n            # The default simple (insecure) protocol (used by NotifyMail), the\n            # other isn't\n            protocol = ('mailto', 'bugger-test' )\n\n            # The default secure protocol (used by NotifyMail), the other isn't\n            secure_protocol = ('mailtos', ['garbage'])\n\n            @staticmethod\n            def parse_url(url, *args, **kwargs):\n                # always parseable\n                return ConfigBase.parse_url(url, verify_host=False)\n            \"\"\"))\n\n    N_MGR.load_modules(path=str(base), name=module_name)\n"
  },
  {
    "path": "tests/test_apprise_asset.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nfrom datetime import timezone, tzinfo\nimport logging\nimport sys\nfrom zoneinfo import ZoneInfo\n\nimport pytest\n\nfrom apprise.asset import AppriseAsset\n\nlogging.disable(logging.CRITICAL)\n\n# Ensure we don't create .pyc files for these tests\nsys.dont_write_bytecode = True\n\n\ndef test_timezone():\n    \"asset: timezone() testing\"\n    asset = AppriseAsset(timezone=\"utc\")\n    assert isinstance(asset.tzinfo, tzinfo)\n\n    # Default (uses system value)\n    asset = AppriseAsset(timezone=None)\n    assert isinstance(asset.tzinfo, tzinfo)\n\n    # Timezone can also already be a tzinfo object\n    asset = AppriseAsset(timezone=timezone.utc)\n    assert isinstance(asset.tzinfo, tzinfo)\n    asset = AppriseAsset(timezone=ZoneInfo(\"America/Toronto\"))\n    assert isinstance(asset.tzinfo, tzinfo)\n\n    with pytest.raises(AttributeError):\n        AppriseAsset(timezone=object)\n\n    with pytest.raises(AttributeError):\n        AppriseAsset(timezone=\"invalid\")\n"
  },
  {
    "path": "tests/test_apprise_attachments.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom os.path import dirname, getsize, join\nfrom unittest import mock\n\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAsset, AttachmentManager\nfrom apprise.apprise_attachment import AppriseAttachment\nfrom apprise.attachment import AttachBase\nfrom apprise.common import ContentLocation\n\nlogging.disable(logging.CRITICAL)\n\nTEST_VAR_DIR = join(dirname(__file__), \"var\")\n\n# Grant access to our Attachment Manager Singleton\nA_MGR = AttachmentManager()\n\n\ndef test_apprise_attachment():\n    \"\"\"\n    API: AppriseAttachment basic testing\n\n    \"\"\"\n\n    # Create ourselves an attachment object\n    aa = AppriseAttachment()\n\n    # There are no attachents loaded\n    assert len(aa) == 0\n\n    # Object can be directly checked as a boolean; response is False\n    # when there are no entries loaded\n    assert not aa\n\n    # An attachment object using a custom Apprise Asset object\n    # Set a cache expiry of 5 minutes (300 seconds)\n    aa = AppriseAttachment(asset=AppriseAsset(), cache=300)\n\n    # still no attachments added\n    assert len(aa) == 0\n\n    # Add a file by it's path\n    path = join(TEST_VAR_DIR, \"apprise-test.gif\")\n    assert aa.add(path)\n\n    # There is now 1 attachment\n    assert len(aa) == 1\n\n    # our attachment took on our cache value\n    assert aa[0].cache == 300\n\n    # we can test the object as a boolean and get a value of True now\n    assert aa\n\n    # Add another entry already in it's AttachBase format\n    response = AppriseAttachment.instantiate(path, cache=True)\n    assert isinstance(response, AttachBase)\n    assert aa.add(response, asset=AppriseAsset())\n\n    # There is now 2 attachments\n    assert len(aa) == 2\n\n    # Cache is initialized to True\n    assert aa[1].cache is True\n\n    # Reset our object\n    aa = AppriseAttachment()\n\n    # We can add by lists as well in a variety of formats\n    attachments = (\n        path,\n        f\"file://{path}?name=newfilename.gif?cache=120\",\n        AppriseAttachment.instantiate(\n            f\"file://{path}?name=anotherfilename.gif\", cache=100\n        ),\n    )\n\n    # Add them\n    assert aa.add(attachments, cache=False)\n\n    # There is now 3 attachments\n    assert len(aa) == 3\n\n    # Take on our fixed cache value of False.\n    # The last entry will have our set value of 100\n    assert aa[0].cache is False\n    # Even though we set a value of 120, we take on the value of False because\n    # it was forced on the instantiate call\n    assert aa[1].cache is False\n    assert aa[2].cache == 100\n\n    # We can pop the last element off of the list as well\n    attachment = aa.pop()\n    assert isinstance(attachment, AttachBase)\n    # we can test of the attachment is valid using a boolean check:\n    assert attachment\n    assert len(aa) == 2\n    assert attachment.path == path\n    assert attachment.name == \"anotherfilename.gif\"\n    assert attachment.mimetype == \"image/gif\"\n\n    # elements can also be directly indexed\n    assert isinstance(aa[0], AttachBase)\n    assert isinstance(aa[1], AttachBase)\n\n    with pytest.raises(IndexError):\n        aa[2]\n\n    # We can iterate over attachments too:\n    for count, a in enumerate(aa):\n        assert isinstance(a, AttachBase)\n\n        # we'll never iterate more then the number of entries in our object\n        assert count < len(aa)\n\n    # Get the file-size of our image\n    expected_size = getsize(path) * len(aa)\n\n    # verify that's what we get as a result\n    assert aa.size() == expected_size\n\n    # Attachments can also be loaded during the instantiation of the\n    # AppriseAttachment object\n    aa = AppriseAttachment(attachments)\n\n    # There is now 3 attachments\n    assert len(aa) == 3\n\n    # Reset our object\n    aa.clear()\n    assert len(aa) == 0\n    assert not aa\n\n    assert aa.add(\n        AppriseAttachment.instantiate(\n            f\"file://{path}?name=andanother.png&cache=Yes\"\n        )\n    )\n    assert aa.add(\n        AppriseAttachment.instantiate(\n            f\"file://{path}?name=andanother.png&cache=No\"\n        )\n    )\n    AppriseAttachment.instantiate(\n        f\"file://{path}?name=andanother.png&cache=600\"\n    )\n    assert aa.add(\n        AppriseAttachment.instantiate(\n            f\"file://{path}?name=andanother.png&cache=600\"\n        )\n    )\n\n    assert len(aa) == 3\n    assert aa[0].cache is True\n    assert aa[1].cache is False\n    assert aa[2].cache == 600\n\n    # Negative cache are not allowed\n    assert not aa.add(\n        AppriseAttachment.instantiate(\n            f\"file://{path}?name=andanother.png&cache=-600\"\n        )\n    )\n\n    # Invalid cache value\n    assert not aa.add(\n        AppriseAttachment.instantiate(\n            f\"file://{path}?name=andanother.png\", cache=\"invalid\"\n        )\n    )\n\n    # No length change\n    assert len(aa) == 3\n\n    # Reset our object\n    aa.clear()\n\n    # Garbage in produces garbage out\n    assert aa.add(None) is False\n    assert aa.add(object()) is False\n    assert aa.add(42) is False\n\n    # length remains unchanged\n    assert len(aa) == 0\n\n    # We can add by lists as well in a variety of formats\n    attachments = (\n        None,\n        object(),\n        42,\n        \"garbage://\",\n    )\n\n    # Add our attachments\n    assert aa.add(attachments) is False\n\n    # length remains unchanged\n    assert len(aa) == 0\n\n    # if instantiating attachments from the class, it will throw a TypeError\n    # if attachments couldn't be loaded\n    with pytest.raises(TypeError):\n        AppriseAttachment(\"garbage://\")\n\n    # Load our other attachment types\n    aa = AppriseAttachment(location=ContentLocation.LOCAL)\n\n    # Hosted type won't allow us to import files\n    aa = AppriseAttachment(location=ContentLocation.HOSTED)\n    assert len(aa) == 0\n\n    # Add our attachments defined a the head of this function\n    aa.add(attachments)\n\n    # Our length is still zero because we can't import files in\n    # a hosted environment\n    assert len(aa) == 0\n\n    # Inaccessible type prevents the adding of new stuff\n    aa = AppriseAttachment(location=ContentLocation.INACCESSIBLE)\n    assert len(aa) == 0\n\n    # Add our attachments defined a the head of this function\n    aa.add(attachments)\n\n    # Our length is still zero\n    assert len(aa) == 0\n\n    with pytest.raises(TypeError):\n        # Invalid location specified\n        AppriseAttachment(location=\"invalid\")\n\n    # test cases when file simply doesn't exist\n    aa = AppriseAttachment(\"file://non-existant-file.png\")\n    # Our length is still 1\n    assert len(aa) == 1\n    # Our object will still return a True\n    assert aa\n\n    # However our indexed entry will not\n    assert not aa[0]\n\n    # length will return 0\n    assert len(aa[0]) == 0\n\n    # Total length will also return 0\n    assert aa.size() == 0\n\n\n@mock.patch(\"requests.request\")\ndef test_apprise_attachment_truncate(mock_request):\n    \"\"\"\n    API: AppriseAttachment when truncation in place\n\n    \"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = response\n\n    # our Apprise Object\n    ap_obj = Apprise()\n\n    # Add ourselves an object set to truncate\n    ap_obj.add(\"json://localhost/?method=GET&overflow=truncate\")\n\n    # Create ourselves an attachment object\n    aa = AppriseAttachment()\n\n    # There are no attachents loaded\n    assert len(aa) == 0\n\n    # Object can be directly checked as a boolean; response is False\n    # when there are no entries loaded\n    assert not aa\n\n    # Add 2 attachments\n    assert aa.add(join(TEST_VAR_DIR, \"apprise-test.gif\"))\n    assert aa.add(join(TEST_VAR_DIR, \"apprise-test.png\"))\n\n    assert mock_request.call_count == 0\n    assert ap_obj.notify(body=\"body\", title=\"title\", attach=aa)\n\n    assert mock_request.call_count == 1\n\n    # Our first item was truncated, so only 1 attachment\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"GET\"\n    dataset = json.loads(details[1][\"data\"])\n    assert len(dataset[\"attachments\"]) == 1\n\n    # Reset our object\n    mock_request.reset_mock()\n\n    # our Apprise Object\n    ap_obj = Apprise()\n\n    # Add ourselves an object set to upstream\n    ap_obj.add(\"json://localhost/?method=GET&overflow=upstream\")\n\n    # Create ourselves an attachment object\n    aa = AppriseAttachment()\n\n    # Add 2 attachments\n    assert aa.add(join(TEST_VAR_DIR, \"apprise-test.gif\"))\n    assert aa.add(join(TEST_VAR_DIR, \"apprise-test.png\"))\n\n    assert mock_request.call_count == 0\n    assert ap_obj.notify(body=\"body\", title=\"title\", attach=aa)\n\n    assert mock_request.call_count == 1\n\n    # Our item was not truncated, so all attachments\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"GET\"\n    dataset = json.loads(details[1][\"data\"])\n    assert len(dataset[\"attachments\"]) == 2\n\n\ndef test_apprise_attachment_instantiate():\n    \"\"\"\n    API: AppriseAttachment.instantiate()\n\n    \"\"\"\n    assert (\n        AppriseAttachment.instantiate(\"file://?\", suppress_exceptions=True)\n        is None\n    )\n\n    assert (\n        AppriseAttachment.instantiate(\"invalid://?\", suppress_exceptions=True)\n        is None\n    )\n\n    class BadAttachType(AttachBase):\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n            # We fail whenever we're initialized\n            raise TypeError()\n\n    # Store our bad attachment type in our schema map\n    A_MGR[\"bad\"] = BadAttachType\n\n    with pytest.raises(TypeError):\n        AppriseAttachment.instantiate(\"bad://path\", suppress_exceptions=False)\n\n    # Same call but exceptions suppressed\n    assert (\n        AppriseAttachment.instantiate(\"bad://path\", suppress_exceptions=True)\n        is None\n    )\n\n\ndef test_attachment_matrix_dynamic_importing(tmpdir):\n    \"\"\"\n    API: Apprise() Attachment Matrix Importing\n\n    \"\"\"\n\n    # Make our new path valid\n    suite = tmpdir.mkdir(\"apprise_attach_test_suite\")\n    suite.join(\"__init__.py\").write(\"\")\n\n    module_name = \"badattach\"\n\n    # Create a base area to work within\n    base = suite.mkdir(module_name)\n    base.join(\"__init__.py\").write(\"\")\n\n    # Test no app_id\n    base.join(\"AttachBadFile1.py\").write(cleandoc(\"\"\"\n        class AttachBadFile1:\n            pass\n        \"\"\"))\n\n    # No class of the same name\n    base.join(\"AttachBadFile2.py\").write(cleandoc(\"\"\"\n        class BadClassName:\n            pass\n        \"\"\"))\n\n    # Exception thrown\n    base.join(\"AttachBadFile3.py\").write(\"\"\"raise ImportError()\"\"\")\n\n    # Utilizes a schema:// already occupied (as string)\n    base.join(\"AttachGoober.py\").write(cleandoc(\"\"\"\n        from apprise import AttachBase\n        class AttachGoober(AttachBase):\n            # This class tests the fact we have a new class name, but we're\n            # trying to over-ride items previously used\n\n            # The default simple (insecure) protocol\n            protocol = 'http'\n\n            # The default secure protocol\n            secure_protocol = 'https'\n        \"\"\"))\n\n    # Utilizes a schema:// already occupied (as tuple)\n    base.join(\"AttachBugger.py\").write(cleandoc(\"\"\"\n        from apprise import AttachBase\n        class AttachBugger(AttachBase):\n            # This class tests the fact we have a new class name, but we're\n            # trying to over-ride items previously used\n\n            # The default simple (insecure) protocol\n            protocol = ('http', 'bugger-test' )\n\n            # The default secure protocol\n            secure_protocol = ('https', 'bugger-tests')\n        \"\"\"))\n\n    A_MGR.load_modules(path=str(base), name=module_name)\n"
  },
  {
    "path": "tests/test_apprise_cli.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom importlib import reload\nfrom inspect import cleandoc\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom os.path import dirname, join\nimport re\nimport sys\nfrom typing import ClassVar\nfrom unittest import mock\n\nfrom click.testing import CliRunner\nfrom helpers import environ\nimport pytest\nimport requests\n\nfrom apprise import NotificationManager, NotifyBase, cli\nfrom apprise.locale import gettext_lazy as _\nfrom apprise.plugins.base import RequirementsSpec\n\nlogging.disable(logging.CRITICAL)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\ndef test_apprise_cli_nux_env(tmpdir):\n    \"\"\"\n    CLI: Nux Environment\n\n    \"\"\"\n\n    class GoodNotification(NotifyBase):\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay (when passing --disable-async)\n            return True\n\n        async def async_notify(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        def url(self, *args, **kwargs):\n            # Support url()\n            return \"good://\"\n\n    class BadNotification(NotifyBase):\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        async def async_notify(self, **kwargs):\n            # Pretend everything is okay\n            return False\n\n        def url(self, *args, **kwargs):\n            # Support url()\n            return \"bad://\"\n\n    # Set up our notification types\n    N_MGR[\"good\"] = GoodNotification\n    N_MGR[\"bad\"] = BadNotification\n\n    runner = CliRunner()\n    result = runner.invoke(cli.main)\n    # no servers specified; we return 1 (non-zero)\n    assert result.exit_code == 1\n\n    result = runner.invoke(cli.main, [\"-v\"])\n    assert result.exit_code == 1\n\n    result = runner.invoke(cli.main, [\"-vv\"])\n    assert result.exit_code == 1\n\n    result = runner.invoke(cli.main, [\"-vvv\"])\n    assert result.exit_code == 1\n\n    result = runner.invoke(cli.main, [\"-vvvv\"])\n    assert result.exit_code == 1\n\n    # Display version information and exit\n    result = runner.invoke(cli.main, [\"-V\"])\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"good://localhost\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    with mock.patch(\"requests.request\") as mock_request:\n        # Prepare Mock\n        mock_request.return_value = requests.Request()\n        mock_request.return_value.status_code = requests.codes.ok\n\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-t\",\n                \"test title\",\n                \"-b\",\n                \"test body\\\\nsNewLine\",\n                # Test using interpret escapes\n                \"-e\",\n                # Use our JSON query\n                \"json://localhost\",\n            ],\n        )\n        assert result.exit_code == 0\n\n        # Test our call count\n        assert mock_request.call_count == 1\n        details = mock_request.call_args_list[0]\n        assert details[0][0] == \"POST\"\n\n        # Our string is now escaped correctly\n        assert (\n            json.loads(details[1][\"data\"]).get(\"message\", \"\")\n            == \"test body\\nsNewLine\"\n        )\n\n        # Reset\n        mock_request.reset_mock()\n\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-t\",\n                \"test title\",\n                \"-b\",\n                \"test body\\\\nsNewLine\",\n                # No -e switch at all (so we don't escape the above)\n                # Use our JSON query\n                \"json://localhost\",\n            ],\n        )\n        assert result.exit_code == 0\n\n        # Test our call count\n        assert mock_request.call_count == 1\n        details = mock_request.call_args_list[0]\n        assert details[0][0] == \"POST\"\n\n        # Our string is now escaped correctly\n        assert (\n            json.loads(details[1][\"data\"]).get(\"message\", \"\")\n            == \"test body\\\\nsNewLine\"\n        )\n\n    # Run in synchronous mode\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"good://localhost\",\n            \"--disable-async\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test Debug Mode (--debug)\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"good://localhost\",\n            \"--debug\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test Debug Mode (-D)\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"good://localhost\",\n            \"-D\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"good://localhost\",\n        ],\n        input=\"test stdin body\\n\",\n    )\n    assert result.exit_code == 0\n\n    # Run in synchronous mode\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"good://localhost\",\n            \"--disable-async\",\n        ],\n        input=\"test stdin body\\n\",\n    )\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"bad://localhost\",\n        ],\n    )\n    assert result.exit_code == 1\n\n    # Run in synchronous mode\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"bad://localhost\",\n            \"-Da\",\n        ],\n    )\n    assert result.exit_code == 1\n\n    # Testing with the --dry-run flag reveals a successful response since we\n    # don't actually execute the bad:// notification; we only display it\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"bad://localhost\",\n            \"--dry-run\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Write a simple text based configuration file\n    t = tmpdir.mkdir(\"apprise-obj\").join(\"apprise\")\n    buf = f\"\"\"\n    # Include ourselves\n    include {t!s}\n\n    taga,tagb=good://localhost\n    tagc=good://nuxref.com\n    \"\"\"\n    t.write(buf)\n\n    # This will read our configuration and not send any notices at all\n    # because we assigned tags to all of our urls and didn't identify\n    # a specific match below.\n\n    # 'include' reference in configuration file would have included the file a\n    # second time (since recursion default is 1).\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"test config\",\n            \"--config\",\n            str(t),\n        ],\n    )\n    # Even when recursion take place, tags are all honored\n    # so 2 is returned because nothing was notified\n    assert result.exit_code == 3\n\n    # This will send out 1 notification because our tag matches\n    # one of the entries above\n    # translation: has taga\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"has taga\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"taga\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test recursion\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"tagc\",\n            # Invalid entry specified for recursion\n            \"-R\",\n            \"invalid\",\n        ],\n    )\n    assert result.exit_code == 2\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"tagc\",\n            # missing entry specified for recursion\n            \"--recursive-depth\",\n        ],\n    )\n    assert result.exit_code == 2\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"tagc\",\n            # Disable recursion (thus inclusion will be ignored)\n            \"-R\",\n            \"0\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test recursion\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-t\",\n            \"test title\",\n            \"-b\",\n            \"test body\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"tagc\",\n            # Recurse up to 5 times\n            \"--recursion-depth\",\n            \"5\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # This will send out 2 notifications because by specifying 2 tag\n    # entries, we 'or' them together:\n    # translation: has taga or tagb or tagd\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"has taga OR tagc OR tagd\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"taga\",\n            \"--tag\",\n            \"tagc\",\n            \"--tag\",\n            \"tagd\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Write a simple text based configuration file\n    t = tmpdir.mkdir(\"apprise-obj2\").join(\"apprise-test2\")\n    buf = \"\"\"\n    good://localhost/1\n    good://localhost/2\n    good://localhost/3\n    good://localhost/4\n    good://localhost/5\n    myTag=good://localhost/6\n    \"\"\"\n    t.write(buf)\n\n    # This will read our configuration and send a notification to\n    # the first 5 entries in the list, but not the one that has\n    # the tag associated with it\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"test config\",\n            \"--config\",\n            str(t),\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test our notification type switch (it defaults to info) so we want to\n    # try it as a different value. Should return without a problem\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"# test config\",\n            \"--config\",\n            str(t),\n            \"-n\",\n            \"success\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test our notification type switch when set to something unsupported\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"test config\",\n            \"--config\",\n            str(t),\n            \"--notification-type\",\n            \"invalid\",\n        ],\n    )\n    # An error code of 2 is returned if invalid input is specified on the\n    # command line\n    assert result.exit_code == 2\n\n    # The notification type switch is case-insensitive\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"test config\",\n            \"--config\",\n            str(t),\n            \"--notification-type\",\n            \"WARNING\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test our formatting switch (it defaults to text) so we want to try it as\n    # a different value. Should return without a problem\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"# test config\",\n            \"--config\",\n            str(t),\n            \"-i\",\n            \"markdown\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test our formatting switch when set to something unsupported\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"test config\",\n            \"--config\",\n            str(t),\n            \"--input-format\",\n            \"invalid\",\n        ],\n    )\n    # An error code of 2 is returned if invalid input is specified on the\n    # command line\n    assert result.exit_code == 2\n\n    # The formatting switch is not case sensitive\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"# test config\",\n            \"--config\",\n            str(t),\n            \"--input-format\",\n            \"HTML\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # As a way of ensuring we match the first 5 entries, we can run a\n    # --dry-run against the same result set above and verify the output\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"test config\",\n            \"--config\",\n            str(t),\n            \"--dry-run\",\n        ],\n    )\n    assert result.exit_code == 0\n    lines = re.split(r\"[\\r\\n]\", result.output.strip())\n    # 5 lines of all good:// entries matched + url id underneath\n    assert len(lines) == 10\n    # Verify we match against the remaining good:// entries\n    for i in range(0, 10, 2):\n        assert lines[i].endswith(\"good://\")\n\n    # This will fail because nothing matches mytag. It's case sensitive\n    # and we would only actually match against myTag\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"has mytag\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"mytag\",\n        ],\n    )\n    assert result.exit_code == 3\n\n    # Same command as the one identified above except we set the --dry-run\n    # flag. This causes our list of matched results to be printed only.\n    # However, since we don't match anything; we still fail with a return code\n    # of 2.\n    result = runner.invoke(\n        cli.main,\n        [\"-b\", \"has mytag\", \"--config\", str(t), \"--tag\", \"mytag\", \"--dry-run\"],\n    )\n    assert result.exit_code == 3\n\n    # Here is a case where we get what was expected; we also attach a file\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"has myTag\",\n            \"--config\",\n            str(t),\n            \"--attach\",\n            join(dirname(__file__), \"var\", \"apprise-test.gif\"),\n            \"--tag\",\n            \"myTag\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Testing with the --dry-run flag reveals the same positive results\n    # because there was at least one match\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"has myTag\",\n            \"--config\",\n            str(t),\n            \"--tag\",\n            \"myTag\",\n            \"--dry-run\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    #\n    # Test environment variables\n    #\n    # Write a simple text based configuration file\n    t2 = tmpdir.mkdir(\"apprise-obj-env\").join(\"apprise\")\n    buf = \"\"\"\n    # A general one\n    good://localhost\n\n    # A failure (if we use the fail tag)\n    fail=bad://localhost\n\n    # A normal one tied to myTag\n    myTag=good://nuxref.com\n    \"\"\"\n    t2.write(buf)\n\n    with environ(APPRISE_URLS=\"good://localhost\"):\n        # This will load okay because we defined the environment\n        # variable with a valid URL\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"test environment\",\n                # Test that we ignore our tag\n                \"--tag\",\n                \"mytag\",\n            ],\n        )\n        assert result.exit_code == 0\n\n        # Same action but without --tag\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"test environment\",\n            ],\n        )\n        assert result.exit_code == 0\n\n    with (\n        mock.patch(\"apprise.cli.DEFAULT_CONFIG_PATHS\", []),\n        environ(APPRISE_URLS=\"      \"),\n    ):\n        # An empty string is not valid and therefore not loaded so the below\n        # fails. We override the DEFAULT_CONFIG_PATHS because we don't want to\n        # detect ones loaded on the machine running the unit tests\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"test environment\",\n            ],\n        )\n        assert result.exit_code == 1\n\n    with environ(APPRISE_URLS=\"bad://localhost\"):\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"test environment\",\n            ],\n        )\n        assert result.exit_code == 1\n\n        # If we specify an inline URL, it will over-ride the environment\n        # variable\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-t\",\n                \"test title\",\n                \"-b\",\n                \"test body\",\n                \"good://localhost\",\n            ],\n        )\n        assert result.exit_code == 0\n\n        # A Config file also over-rides the environment variable if\n        # specified on the command line:\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"has myTag\",\n                \"--config\",\n                str(t2),\n                \"--tag\",\n                \"myTag\",\n            ],\n        )\n        assert result.exit_code == 0\n\n    with environ(APPRISE_CONFIG=str(t2)):\n        # Deprecated test case\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"has myTag\",\n                \"--tag\",\n                \"myTag\",\n            ],\n        )\n        assert result.exit_code == 0\n\n    with environ(APPRISE_CONFIG_PATH=str(t2)):\n        # Our configuration file will load from our environmment variable\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"has myTag\",\n                \"--tag\",\n                \"myTag\",\n            ],\n        )\n        assert result.exit_code == 0\n\n    with environ(APPRISE_CONFIG_PATH=str(t2) + \";/another/path\"):\n        # Our configuration file will load from our environmment variable\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"has myTag\",\n                \"--tag\",\n                \"myTag\",\n            ],\n        )\n        assert result.exit_code == 0\n\n    with (\n        mock.patch(\"apprise.cli.DEFAULT_CONFIG_PATHS\", []),\n        environ(APPRISE_CONFIG=\"      \"),\n    ):\n        # We will fail to send the notification as no path was specified.\n        # We override the DEFAULT_CONFIG_PATHS because we don't want to detect\n        # ones loaded on the machine running the unit tests\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"my message\",\n            ],\n        )\n        assert result.exit_code == 1\n\n    with environ(APPRISE_CONFIG=\"garbage/file/path.yaml\"):\n        # We will fail to send the notification as the path\n        # specified is not loadable\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"my message\",\n            ],\n        )\n        assert result.exit_code == 1\n\n        # We can force an over-ride by specifying a config file on the\n        # command line options:\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"has myTag\",\n                \"--config\",\n                str(t2),\n                \"--tag\",\n                \"myTag\",\n            ],\n        )\n        assert result.exit_code == 0\n\n    # Just a general test; if both the --config and urls are specified\n    # then the the urls trumps all\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"has myTag\",\n            \"--config\",\n            str(t2),\n            \"good://localhost\",\n            \"--tag\",\n            \"fail\",\n        ],\n    )\n    # Tags are ignored, URL specified, so it trump config\n    assert result.exit_code == 0\n\n    # we just repeat the test as a proof that it only executes\n    # the urls despite the fact the --config was specified\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"reads the url entry only\",\n            \"--config\",\n            str(t2),\n            \"good://localhost\",\n            \"--tag\",\n            \"fail\",\n        ],\n    )\n    # Tags are ignored, URL specified, so it trump config\n    assert result.exit_code == 0\n\n    # once agian, but we call bad://\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-b\",\n            \"reads the url entry only\",\n            \"--config\",\n            str(t2),\n            \"bad://localhost\",\n            \"--tag\",\n            \"myTag\",\n        ],\n    )\n    assert result.exit_code == 1\n\n    # Test Escaping:\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-e\",\n            \"-t\",\n            \"test\\ntitle\",\n            \"-b\",\n            \"test\\nbody\",\n            \"good://localhost\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test Escaping (without title)\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--interpret-escapes\",\n            \"-b\",\n            \"test\\nbody\",\n            \"good://localhost\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Test Emojis:\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-j\",\n            \"-t\",\n            \":smile:\",\n            \"-b\",\n            \":grin:\",\n            \"good://localhost\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--interpret-emojis\",\n            \"-t\",\n            \":smile:\",\n            \"-b\",\n            \":grin:\",\n            \"good://localhost\",\n        ],\n    )\n    assert result.exit_code == 0\n\n\ndef test_apprise_cli_modules(tmpdir):\n    \"\"\"\n    CLI: --plugin (-P)\n\n    \"\"\"\n\n    runner = CliRunner()\n\n    #\n    # Loading of modules works correctly\n    #\n    notify_cmod_base = tmpdir.mkdir(\"cli_modules\")\n    notify_cmod = notify_cmod_base.join(\"hook.py\")\n    notify_cmod.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    @notify(on=\"climod\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n    \"\"\"))\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            str(notify_cmod),\n            \"-t\",\n            \"title\",\n            \"-b\",\n            \"body\",\n            \"climod://\",\n        ],\n    )\n\n    assert result.exit_code == 0\n\n    # Test -P\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-P\",\n            str(notify_cmod),\n            \"-t\",\n            \"title\",\n            \"-b\",\n            \"body\",\n            \"climod://\",\n        ],\n    )\n\n    assert result.exit_code == 0\n\n    # Test double hooks\n    notify_cmod2 = notify_cmod_base.join(\"hook2.py\")\n    notify_cmod2.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    @notify(on=\"climod2\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n    \"\"\"))\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            str(notify_cmod),\n            \"--plugin-path\",\n            str(notify_cmod2),\n            \"-t\",\n            \"title\",\n            \"-b\",\n            \"body\",\n            \"climod://\",\n            \"climod2://\",\n        ],\n    )\n\n    assert result.exit_code == 0\n\n    with environ(\n        APPRISE_PLUGIN_PATH=str(notify_cmod) + \";\" + str(notify_cmod2)\n    ):\n        # Leverage our environment variables to specify the plugin path\n        result = runner.invoke(\n            cli.main,\n            [\n                \"-b\",\n                \"body\",\n                \"climod://\",\n                \"climod2://\",\n            ],\n        )\n\n        assert result.exit_code == 0\n\n\n@pytest.mark.skipif(\n    sys.platform == \"win32\", reason=\"Unreliable results to be determined\"\n)\ndef test_apprise_cli_persistent_storage(tmpdir):\n    \"\"\"\n    CLI: test persistent storage\n\n    \"\"\"\n\n    # This is a made up class that is just used to verify\n    class NoURLIDNotification(NotifyBase):\n        \"\"\"A no URL ID.\"\"\"\n\n        # Update URL identifier\n        url_identifier = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        def send(self, **kwargs):\n\n            # Pretend everything is okay\n            return True\n\n        def url(self, *args, **kwargs):\n            # Support URL\n            return \"noper://\"\n\n        def parse_url(self, *args, **kwargs):\n            # parse our url\n            return {\"schema\": \"noper\"}\n\n    # This is a made up class that is just used to verify\n    class TestNotification(NotifyBase):\n        \"\"\"A Testing Script.\"\"\"\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        def send(self, **kwargs):\n\n            # Test our persistent settings\n            self.store.set(\"key\", \"value\")\n            assert self.store.get(\"key\") == \"value\"\n\n            # Pretend everything is okay\n            return True\n\n        def url(self, *args, **kwargs):\n            # Support URL\n            return \"test://\"\n\n        def parse_url(self, *args, **kwargs):\n            # parse our url\n            return {\"schema\": \"test\"}\n\n    # assign test:// to our  notification defined above\n    N_MGR[\"test\"] = TestNotification\n    N_MGR[\"noper\"] = NoURLIDNotification\n\n    # Write a simple text based configuration file\n    config = tmpdir.join(\"apprise.cfg\")\n    buf = cleandoc(\"\"\"\n    # Create a config file we can source easily\n    test=test://\n    noper=noper://\n\n    # Define a second test URL that will\n    two-urls=test://\n\n    # Create another entry that has no tag associatd with it\n    test://?entry=2\n    \"\"\")\n    config.write(buf)\n\n    runner = CliRunner()\n\n    # Generate notification that creates persistent data\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    # our persist storage has not been created yet\n    stdout = result.stdout.strip()\n\n    # Click output can wrap based on terminal width, and storage backend\n    # sizes are not stable across Python/OS variations. Validate semantics.\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+unused\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n    assert re.search(r\"(?m)^\\s*-\\s+test://\\s*$\", stdout)\n\n    # An invalid mode specified\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--storage-mode\",\n            \"invalid\",\n            \"--config\",\n            str(config),\n            \"-g\",\n            \"test\",\n            \"-t\",\n            \"title\",\n            \"-b\",\n            \"body\",\n        ],\n    )\n    # Bad mode specified\n    assert result.exit_code == 2\n\n    # Invalid uid lenth specified\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--storage-mode\",\n            \"flush\",\n            \"--storage-uid-length\",\n            1,\n            \"--config\",\n            str(config),\n            \"-g\",\n            \"test\",\n            \"-t\",\n            \"title\",\n            \"-b\",\n            \"body\",\n        ],\n    )\n    # storage uid length to small\n    assert result.exit_code == 2\n\n    # No files written yet; just config file exists\n    dir_content = os.listdir(str(tmpdir))\n    assert len(dir_content) == 1\n    assert \"apprise.cfg\" in dir_content\n\n    # Generate notification that creates persistent data\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--storage-mode\",\n            \"flush\",\n            \"--config\",\n            str(config),\n            \"-t\",\n            \"title\",\n            \"-b\",\n            \"body\",\n            \"-g\",\n            \"test\",\n        ],\n    )\n    # We parsed our data accordingly\n    assert result.exit_code == 0\n\n    dir_content = os.listdir(str(tmpdir))\n    assert len(dir_content) == 2\n    assert \"apprise.cfg\" in dir_content\n    assert \"ea482db7\" in dir_content\n\n    # Have a look at our storage listings\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # keyword list is not required\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # search on something that won't match\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n            \"nomatch\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    assert not result.stdout.strip()\n\n    # closest match search\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n            # Closest match will hit a result\n            \"ea\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # list is the presumed option if no match\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            # Closest match will hit a result\n            \"ea\",\n        ],\n    )\n    # list our entries successfully again..\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # Search based on tag\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n            # We can match by tags too\n            \"-g\",\n            \"test\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # Prune call but prune-days set incorrectly\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--storage-prune-days\",\n            -1,\n            \"storage\",\n            \"prune\",\n        ],\n    )\n    # storage prune days is invalid\n    assert result.exit_code == 2\n\n    # Create a tmporary namespace\n    tmpdir.mkdir(\"namespace\")\n\n    # Generates another listing\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n        ],\n    )\n\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n    assert re.match(\n        r\".*\\s*[0-9]\\.\\s+namespace\\s+0\\.00B\\s+stale.*\",\n        stdout,\n        (re.MULTILINE | re.DOTALL),\n    )\n\n    # Generates another listing but utilize the tag\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"--tag\",\n            \"test\",\n            \"storage\",\n        ],\n    )\n\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n    assert (\n        re.match(\n            r\".*\\s*[0-9]\\.\\s+namespace\\s+0\\.00B\\s+stale.*\",\n            stdout,\n            (re.MULTILINE | re.DOTALL),\n        )\n        is None\n    )\n\n    # Clear all of our accumulated disk space\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"clear\",\n        ],\n    )\n\n    # successful\n    assert result.exit_code == 0\n\n    # Generate another listing\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n        ],\n    )\n\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    # back to unused state and 0 bytes\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+unused\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n    # namespace is gone now\n    assert (\n        re.match(\n            r\".*\\s*[0-9]\\.\\s+namespace\\s+0\\.00B\\s+stale.*\",\n            stdout,\n            (re.MULTILINE | re.DOTALL),\n        )\n        is None\n    )\n\n    # Provide both tags and uid\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"ea\",\n            \"-g\",\n            \"test\",\n        ],\n    )\n\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    # back to unused state and 0 bytes\n    assert re.match(\n        r\"^[0-9]\\.\\s+[a-z0-9_-]{8}\\s+0\\.00B\\s+unused\\s+-\\s+test://$\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # Generate notification that creates persistent data\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--storage-mode\",\n            \"flush\",\n            \"--config\",\n            str(config),\n            \"-t\",\n            \"title\",\n            \"-b\",\n            \"body\",\n            \"-g\",\n            \"test\",\n        ],\n    )\n    # We parsed our data accordingly\n    assert result.exit_code == 0\n\n    # Have a look at our storage listings\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # Prune call but prune-days set incorrectly\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"storage\",\n            \"prune\",\n        ],\n    )\n\n    # Run our prune successfully\n    assert result.exit_code == 0\n\n    # Have a look at our storage listings (expected no change in output)\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+active\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # Prune call but prune-days set incorrectly\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            # zero simulates a full clean\n            \"--storage-prune-days\",\n            0,\n            \"storage\",\n            \"prune\",\n        ],\n    )\n\n    # Run our prune successfully\n    assert result.exit_code == 0\n\n    # Have a look at our storage listings (expected no change in output)\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--storage-path\",\n            str(tmpdir),\n            \"--config\",\n            str(config),\n            \"storage\",\n            \"list\",\n        ],\n    )\n    # list our entries\n    assert result.exit_code == 0\n\n    # Note: An prune/expiry of zero gets everything except for MS Windows\n    # during testing only.\n    # Until this can be resolved, this is the section of the test that\n    # caused us to disable it in MS Windows\n    stdout = result.stdout.strip()\n    assert re.search(\n        r\"^1\\.\\s+[a-z0-9_-]{8}\\s+\\d+(?:\\.\\d{2})?[KMGT]?B\\s+unused\\b\",\n        stdout,\n        re.MULTILINE,\n    )\n\n    # New Temporary namespace\n    new_persistent_base = tmpdir.mkdir(\"namespace\")\n    with environ(APPRISE_STORAGE_PATH=str(new_persistent_base)):\n        # Reload our module\n        reload(cli)\n\n        # Nothing in our directory yet\n        dir_content = os.listdir(str(new_persistent_base))\n        assert len(dir_content) == 0\n\n        # Generate notification that creates persistent data\n        # storage path is pulled out of our environment variable\n        result = runner.invoke(\n            cli.main,\n            [\n                \"--storage-mode\",\n                \"flush\",\n                \"--config\",\n                str(config),\n                \"-t\",\n                \"title\",\n                \"-b\",\n                \"body\",\n                \"-g\",\n                \"test\",\n            ],\n        )\n        # We parsed our data accordingly\n        assert result.exit_code == 0\n\n        # Now content exists\n        dir_content = os.listdir(str(new_persistent_base))\n        assert len(dir_content) == 1\n\n    # Reload our module with our environment variable gone\n    reload(cli)\n\n    # Clear loaded modules\n    N_MGR.unload_modules()\n\n\ndef test_apprise_cli_details(tmpdir):\n    \"\"\"\n    CLI: --details (-l)\n\n    \"\"\"\n\n    runner = CliRunner()\n\n    #\n    # Testing the printout of our details\n    #   --details or -l\n    #\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--details\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-l\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Clear loaded modules\n    N_MGR.unload_modules()\n\n    # This is a made up class that is just used to verify\n    class TestReq01Notification(NotifyBase):\n        \"\"\"This class is used to test various requirement configurations.\"\"\"\n\n        # Set some requirements\n        requirements: ClassVar[RequirementsSpec] = {\n            \"packages_required\": [\n                \"cryptography <= 3.4\",\n                \"ultrasync\",\n            ],\n            \"packages_recommended\": \"django\",\n        }\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req01\"] = TestReq01Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq02Notification(NotifyBase):\n        \"\"\"This class is used to test various requirement configurations.\"\"\"\n\n        # Just not enabled at all\n        enabled = False\n\n        # Set some requirements\n        requirements: ClassVar[RequirementsSpec] = {\n            # None and/or [] is implied, but jsut to show that the code won't\n            # crash if explicitly set this way:\n            \"packages_required\": None,\n            \"packages_recommended\": [\n                \"cryptography <= 3.4\",\n            ],\n        }\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req02\"] = TestReq02Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq03Notification(NotifyBase):\n        \"\"\"This class is used to test various requirement configurations.\"\"\"\n\n        # Set some requirements (but additionally include a details over-ride)\n        requirements: ClassVar[RequirementsSpec] = {\n            # We can over-ride the default details assigned to our plugin if\n            # specified\n            \"details\": _(\"some specified requirement details\"),\n            # We can set a string value as well (it does not have to be a list)\n            \"packages_recommended\": \"cryptography <= 3.4\",\n        }\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req03\"] = TestReq03Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq04Notification(NotifyBase):\n        \"\"\"This class is used to test a case where our requirements is fixed to\n        a None.\"\"\"\n\n        # This is the same as saying there are no requirements\n        requirements = None\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req04\"] = TestReq04Notification\n\n    # This is a made up class that is just used to verify\n    class TestReq05Notification(NotifyBase):\n        \"\"\"This class is used to test a case where only packages_recommended is\n        identified.\"\"\"\n\n        requirements: ClassVar[RequirementsSpec] = {\n            \"packages_recommended\": \"cryptography <= 3.4\"\n        }\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"req05\"] = TestReq05Notification\n\n    class TestDisabled01Notification(NotifyBase):\n        \"\"\"This class is used to test a pre-disabled state.\"\"\"\n\n        # Just flat out disable our service\n        enabled = False\n\n        # we'll use this as a key to make our service easier to find\n        # in the next part of the testing\n        service_name = \"na01\"\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"na01\"] = TestDisabled01Notification\n\n    class TestDisabled02Notification(NotifyBase):\n        \"\"\"This class is used to test a post-disabled state.\"\"\"\n\n        # we'll use this as a key to make our service easier to find\n        # in the next part of the testing\n        service_name = \"na02\"\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n            # enable state changes **AFTER** we initialize\n            self.enabled = False\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"na02\"] = TestDisabled02Notification\n\n    # We'll add a good notification to our list\n    class TesEnabled01Notification(NotifyBase):\n        \"\"\"This class is just a simple enabled one.\"\"\"\n\n        # we'll use this as a key to make our service easier to find\n        # in the next part of the testing\n        service_name = \"good\"\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay (so we don't break other tests)\n            return True\n\n    N_MGR[\"good\"] = TesEnabled01Notification\n\n    # Verify that we can pass through all of our different details\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--details\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"-l\",\n        ],\n    )\n    assert result.exit_code == 0\n\n    # Clear loaded modules\n    N_MGR.unload_modules()\n\n\ndef test_apprise_cli_print_help():\n    \"\"\"\n    CLI: --help (-h)\n\n    \"\"\"\n    runner = CliRunner()\n\n    # Clear our working variables so they don't obstruct the next test\n    # This simulates an actual call from the CLI.  Unfortunately through\n    # testing were occupying the same memory space so our singleton's\n    # have already been populated\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Print help and exit\n    result = runner.invoke(cli.main, [\"--help\"])\n    assert result.exit_code == 0\n\n    result = runner.invoke(cli.main, [\"-h\"])\n    assert result.exit_code == 0\n\n\n@mock.patch(\"requests.request\")\ndef test_apprise_cli_plugin_loading(mock_request, tmpdir):\n    \"\"\"\n    CLI: --plugin-path (-P)\n\n    \"\"\"\n    # Prepare Mock\n    mock_request.return_value = requests.Request()\n    mock_request.return_value.status_code = requests.codes.ok\n\n    runner = CliRunner()\n\n    # Clear our working variables so they don't obstruct the next test\n    # This simulates an actual call from the CLI.  Unfortunately through\n    # testing were occupying the same memory space so our singleton's\n    # have already been populated\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Test a path that has no files to load in it\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            join(str(tmpdir), \"invalid_path\"),\n            \"-b\",\n            \"test\\nbody\",\n            \"json://localhost\",\n        ],\n    )\n    # The path is silently loaded but fails... it's okay because the\n    # notification we're choosing to notify does exist\n    assert result.exit_code == 0\n\n    # Directories that don't exist passed in by the CLI aren't even scanned\n    assert len(N_MGR._paths_previously_scanned) == 0\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Test our current existing path that has no entries in it\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            str(tmpdir.mkdir(\"empty\")),\n            \"-b\",\n            \"test\\nbody\",\n            \"json://localhost\",\n        ],\n    )\n    # The path is silently loaded but fails... it's okay because the\n    # notification we're choosing to notify does exist\n    assert result.exit_code == 0\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert join(str(tmpdir), \"empty\") in N_MGR._paths_previously_scanned\n\n    # However there was nothing to load\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Clear our working variables so they don't obstruct the next test\n    # This simulates an actual call from the CLI.  Unfortunately through\n    # testing were occupying the same memory space so our singleton's\n    # have already been populated\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Prepare ourselves a file to work with\n    notify_hook_a_base = tmpdir.mkdir(\"random\")\n    notify_hook_a = notify_hook_a_base.join(\"myhook01.py\")\n    notify_hook_a.write(cleandoc(\"\"\"\n    raise ImportError\n    \"\"\"))\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            str(notify_hook_a),\n            \"-b\",\n            \"test\\nbody\",\n            # A custom hook:\n            \"clihook://\",\n        ],\n    )\n    # It doesn't exist so it will fail\n    # meanwhile we would have failed to load the myhook path\n    assert result.exit_code == 1\n\n    # The path is silently loaded but fails... it's okay because the\n    # notification we're choosing to notify does exist\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert str(notify_hook_a) in N_MGR._paths_previously_scanned\n    # However there was nothing to load\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Prepare ourselves a file to work with\n    notify_hook_aa = notify_hook_a_base.join(\"myhook02.py\")\n    notify_hook_aa.write(cleandoc(\"\"\"\n    garbage entry\n    \"\"\"))\n\n    N_MGR.plugins()\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            str(notify_hook_aa),\n            \"-b\",\n            \"test\\nbody\",\n            # A custom hook:\n            \"clihook://custom\",\n        ],\n    )\n    # It doesn't exist so it will fail\n    # meanwhile we would have failed to load the myhook path\n    assert result.exit_code == 1\n\n    # The path is silently loaded but fails...\n    # as a result the path stacks with the last\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert str(notify_hook_a) in N_MGR._paths_previously_scanned\n    assert str(notify_hook_aa) in N_MGR._paths_previously_scanned\n    # However there was nothing to load\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Clear our working variables so they don't obstruct the next test\n    # This simulates an actual call from the CLI.  Unfortunately through\n    # testing were occupying the same memory space so our singleton's\n    # have already been populated\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Prepare ourselves a file to work with\n    notify_hook_b = tmpdir.mkdir(\"goodmodule\").join(\"__init__.py\")\n    notify_hook_b.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    # We want to trigger on anyone who configures a call to clihook://\n    @notify(on=\"clihook\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        # A simple test - print to screen\n        print(\"{}: {} - {}\".format(notify_type, title, body))\n\n        # No return (so a return of None) get's translated to True\n\n    # Define another in the same file\n    @notify(on=\"clihookA\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        # A simple test - print to screen\n        print(\"!! {}: {} - {}\".format(notify_type, title, body))\n\n        # No return (so a return of None) get's translated to True\n    \"\"\"))\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            str(tmpdir),\n            \"-b\",\n            \"test body\",\n            # A custom hook:\n            \"clihook://still/valid\",\n        ],\n    )\n\n    # We can detect the goodmodule (which has an __init__.py in it)\n    # so we'll load okay\n    assert result.exit_code == 0\n\n    # Let's see how things got loaded:\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert str(tmpdir) in N_MGR._paths_previously_scanned\n    # absolute path to detected module is also added\n    assert (\n        join(str(tmpdir), \"goodmodule\", \"__init__.py\")\n        in N_MGR._paths_previously_scanned\n    )\n\n    # We also loaded our clihook properly\n    assert len(N_MGR._custom_module_map) == 1\n\n    # We can find our new hook loaded in our schema map now...\n    assert \"clihook\" in N_MGR\n\n    # Capture our key for reference\n    key = next(iter(N_MGR._custom_module_map.keys()))\n\n    # We loaded 2 entries from the same file\n    assert len(N_MGR._custom_module_map[key][\"notify\"]) == 2\n    assert \"clihook\" in N_MGR._custom_module_map[key][\"notify\"]\n    # Converted to lower case\n    assert \"clihooka\" in N_MGR._custom_module_map[key][\"notify\"]\n\n    # Our function name\n    assert (\n        N_MGR._custom_module_map[key][\"notify\"][\"clihook\"][\"fn_name\"]\n        == \"mywrapper\"\n    )\n    # What we parsed from the `on` keyword in the @notify decorator\n    assert (\n        N_MGR._custom_module_map[key][\"notify\"][\"clihook\"][\"url\"]\n        == \"clihook://\"\n    )\n    # our default name Assignment.  This can be-overridden on the @notify\n    # decorator by just adding a name= to the parameter list\n    assert N_MGR[\"clihook\"].service_name == \"Custom - clihook\"\n\n    # Our Base Notification object when initialized:\n    assert (\n        len(N_MGR._module_map[N_MGR._custom_module_map[key][\"name\"]][\"plugin\"])\n        == 2\n    )\n    for plugin in N_MGR._module_map[N_MGR._custom_module_map[key][\"name\"]][\n        \"plugin\"\n    ]:\n        assert isinstance(plugin(), NotifyBase)\n\n    # Clear our working variables so they don't obstruct the next test\n    # This simulates an actual call from the CLI.  Unfortunately through\n    # testing were occupying the same memory space so our singleton's\n    # have already been populated\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n    del N_MGR[\"clihook\"]\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            str(notify_hook_b),\n            \"-b\",\n            \"test body\",\n            # A custom hook:\n            \"clihook://\",\n        ],\n    )\n\n    # Absolute path to __init__.py is okay\n    assert result.exit_code == 0\n\n    # we can verify that it prepares our message\n    assert result.stdout.strip() == \"info:  - test body\"\n\n    # Clear our working variables so they don't obstruct the next test\n    # This simulates an actual call from the CLI.  Unfortunately through\n    # testing were occupying the same memory space so our singleton's\n    # have already been populated\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n    del N_MGR[\"clihook\"]\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            dirname(str(notify_hook_b)),\n            \"-b\",\n            \"test body\",\n            # A custom hook:\n            \"clihook://\",\n        ],\n    )\n\n    # Now we succeed to load our module when pointed to it only because\n    # an __init__.py is found on the inside of it\n    assert result.exit_code == 0\n\n    # we can verify that it prepares our message\n    assert result.stdout.strip() == \"info:  - test body\"\n\n    # Test double paths that are the same; this ensures we only\n    # load the plugin once\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            dirname(str(notify_hook_b)),\n            \"--plugin-path\",\n            str(notify_hook_b),\n            \"--details\",\n        ],\n    )\n\n    # Now we succeed to load our module when pointed to it only because\n    # an __init__.py is found on the inside of it\n    assert result.exit_code == 0\n\n    # Clear our working variables so they don't obstruct the next test\n    # This simulates an actual call from the CLI.  Unfortunately through\n    # testing were occupying the same memory space so our singleton's\n    # have already been populated\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n    del N_MGR[\"clihook\"]\n\n    # Prepare ourselves a file to work with\n    notify_hook_b = tmpdir.mkdir(\"complex\").join(\"complex.py\")\n    notify_hook_b.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    # We can't over-ride an element that already exists\n    # in this case json://\n    @notify(on=\"json\")\n    def mywrapper_01(body, title, notify_type, *args, **kwargs):\n        # Return True (same as None)\n        return True\n\n    @notify(on=\"willfail\", name=\"always failing...\")\n    def mywrapper_02(body, title, notify_type, *args, **kwargs):\n        # Simply fail\n        return False\n\n    @notify(on=\"clihook1\", name=\"the original clihook entry\")\n    def mywrapper_03(body, title, notify_type, *args, **kwargs):\n        # Return True\n        return True\n\n    # This is a duplicate o the entry above, so it can not be\n    # loaded...\n    @notify(on=\"clihook1\", name=\"a duplicate of the clihook entry\")\n    def mywrapper_04(body, title, notify_type, *args, **kwargs):\n        # Return True\n        return True\n\n    # This is where things get realy cool... we can not only\n    # define the schema we want to over-ride, but we can define\n    # some default values to pass into our wrapper function to\n    # act as a base before whatever was actually passed in is\n    # applied ontop.... think of it like templating information\n    @notify(on=\"clihook2://localhost\")\n    def mywrapper_05(body, title, notify_type, *args, **kwargs):\n        # Return True\n        return True\n\n\n    # This can't load because of the defined schema/on definition\n    @notify(on=\"\", name=\"an invalid schema was specified\")\n    def mywrapper_06(body, title, notify_type, *args, **kwargs):\n        return True\n    \"\"\"))\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            join(str(tmpdir), \"complex\"),\n            \"-b\",\n            \"test body\",\n            # A custom hook that does not exist\n            \"clihook://\",\n        ],\n    )\n\n    # Since clihook:// isn't in our complex listing, this will fail\n    assert result.exit_code == 1\n\n    # Let's see how things got loaded\n    assert len(N_MGR._paths_previously_scanned) == 2\n    # Our path we specified on the CLI...\n    assert join(str(tmpdir), \"complex\") in N_MGR._paths_previously_scanned\n\n    # absolute path to detected module is also added\n    assert (\n        join(str(tmpdir), \"complex\", \"complex.py\")\n        in N_MGR._paths_previously_scanned\n    )\n\n    # We loaded our one module successfuly\n    assert len(N_MGR._custom_module_map) == 1\n\n    # We can find our new hook loaded in our SCHEMA_MAP now...\n    assert \"willfail\" in N_MGR\n    assert \"clihook1\" in N_MGR\n    assert \"clihook2\" in N_MGR\n\n    # Capture our key for reference\n    key = next(iter(N_MGR._custom_module_map.keys()))\n\n    assert len(N_MGR._custom_module_map[key][\"notify\"]) == 3\n    assert \"willfail\" in N_MGR._custom_module_map[key][\"notify\"]\n    assert \"clihook1\" in N_MGR._custom_module_map[key][\"notify\"]\n    # We only load 1 instance of the clihook2, the second will fail\n    assert \"clihook2\" in N_MGR._custom_module_map[key][\"notify\"]\n    # We can never load previously created notifications\n    assert \"json\" not in N_MGR._custom_module_map[key][\"notify\"]\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            join(str(tmpdir), \"complex\"),\n            \"-b\",\n            \"test body\",\n            # A custom notification set up for failure\n            \"willfail://\",\n        ],\n    )\n    # Note that the failure of the decorator carries all the way back\n    # to the CLI\n    assert result.exit_code == 1\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            join(str(tmpdir), \"complex\"),\n            \"-b\",\n            \"test body\",\n            # our clihook that returns true\n            \"clihook1://\",\n            # our other loaded clihook\n            \"clihook2://\",\n        ],\n    )\n    # Note that the failure of the decorator carries all the way back\n    # to the CLI\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            join(str(tmpdir), \"complex\"),\n            \"--notification-type\", \"invalid\",\n            \"-b\",\n            \"test body\",\n            # our clihook that returns true\n            \"clihook1://\",\n        ],\n    )\n    # Bad notification type specified\n    assert result.exit_code == 2\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            join(str(tmpdir), \"complex\"),\n            \"-b\",\n            \"-i\", \"warning\"\n            \"test body\",\n            # our clihook that returns true\n            \"clihook1://\",\n        ],\n    )\n    # Bad notification type specified\n    assert result.exit_code == 0\n\n    result = runner.invoke(\n        cli.main,\n        [\n            \"--plugin-path\",\n            join(str(tmpdir), \"complex\"),\n            # Print our custom details to the screen\n            \"--details\",\n        ],\n    )\n    assert \"willfail\" in result.stdout\n    assert \"always failing...\" in result.stdout\n\n    assert \"clihook1\" in result.stdout\n    assert \"the original clihook entry\" in result.stdout\n    assert \"a duplicate of the clihook entry\" not in result.stdout\n\n    assert \"clihook2\" in result.stdout\n    assert \"Custom - clihook2\" in result.stdout\n\n    # Note that the failure of the decorator carries all the way back\n    # to the CLI\n    assert result.exit_code == 0\n\n\n@mock.patch(\"platform.system\")\ndef test_apprise_cli_windows_env(mock_system):\n    \"\"\"\n    CLI: Windows Environment\n\n    \"\"\"\n    # Force a windows environment\n    mock_system.return_value = \"Windows\"\n\n    # Reload our module\n    reload(cli)\n"
  },
  {
    "path": "tests/test_apprise_config.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport sys\nimport time\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    AppriseConfig,\n    ConfigFormat,\n    ConfigurationManager,\n    ContentIncludeMode,\n    NotificationManager,\n    NotifyFormat,\n)\nfrom apprise.config import ConfigBase\nfrom apprise.config.file import ConfigFile\nfrom apprise.plugins import NotifyBase\n\nlogging.disable(logging.CRITICAL)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n# Grant access to our Configuration Manager Singleton\nC_MGR = ConfigurationManager()\n\n\ndef test_apprise_config(tmpdir):\n    \"\"\"\n    API: AppriseConfig basic testing\n\n    \"\"\"\n\n    # Create ourselves a config object\n    ac = AppriseConfig()\n\n    # There are no servers loaded\n    assert len(ac) == 0\n\n    # Object can be directly checked as a boolean; response is False\n    # when there are no entries loaded\n    assert not ac\n\n    # lets try anyway\n    assert len(ac.servers()) == 0\n\n    t = tmpdir.mkdir(\"simple-formatting\").join(\"apprise\")\n    t.write(\"\"\"\n    # A comment line over top of a URL\n    mailto://usera:pass@gmail.com\n\n    # A line with mulitiple tag assignments to it\n    taga,tagb=gnome://\n\n    # Event if there is accidental leading spaces, this configuation\n    # is accepting of htat and will not exclude them\n                tagc=kde://\n\n    # A very poorly structured url\n    sns://:@/\n\n    # Just 1 token provided causes exception\n    sns://T1JJ3T3L2/\n\n    # XML\n    xml://localhost/?+HeaderEntry=Test&:IgnoredEntry=Ignored\n    \"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n\n    # Object can be directly checked as a boolean; response is True\n    # when there is at least one entry\n    assert ac\n\n    # We should be able to read our 4 servers from that\n    assert len(ac.servers()) == 4\n\n    # Get our URL back\n    assert isinstance(ac[0].url(), str)\n\n    # Test cases where our URL is invalid\n    t = tmpdir.mkdir(\"strange-lines\").join(\"apprise\")\n    t.write(\"\"\"\n    # basicly this consists of defined tags and no url\n    tag=\n    \"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t), asset=AppriseAsset())\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n\n    # No urls were set\n    assert len(ac.servers()) == 0\n\n    # Create a ConfigBase object\n    cb = ConfigBase()\n\n    # Test adding of all entries\n    assert ac.add(configs=cb, asset=AppriseAsset(), tag=\"test\") is True\n\n    # Test adding of all entries\n    assert (\n        ac.add(\n            configs=[\n                \"file://?\",\n            ],\n            asset=AppriseAsset(),\n            tag=\"test\",\n        )\n        is False\n    )\n\n    # Test the adding of garbage\n    assert ac.add(configs=object()) is False\n\n    # Try again but enforce our format\n    ac = AppriseConfig(paths=f\"file://{t!s}?format=text\")\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n\n    # No urls were set\n    assert len(ac.servers()) == 0\n\n    #\n    # Test Internatialization and the handling of unicode characters\n    #\n    istr = \"\"\"\n        # Iñtërnâtiônàlization Testing\n        windows://\"\"\"\n\n    # Write our content to our file\n    t = tmpdir.mkdir(\"internationalization\").join(\"apprise\")\n    with open(str(t), \"wb\") as f:\n        f.write(istr.encode(\"latin-1\"))\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n\n    # This will fail because our default encoding is utf-8; however the file\n    # we opened was not; it was latin-1 and could not be parsed.\n    assert len(ac.servers()) == 0\n\n    # Test iterator\n    count = 0\n    for _entry in ac:\n        count += 1\n    assert len(ac) == count\n\n    # We can fix this though; set our encoding to latin-1\n    ac = AppriseConfig(paths=f\"file://{t!s}?encoding=latin-1\")\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n\n    # Our URL should be found\n    assert len(ac.servers()) == 1\n\n    # Get our URL back\n    assert isinstance(ac[0].url(), str)\n\n    # pop an entry from our list\n    assert isinstance(ac.pop(0), ConfigBase)\n\n    # Determine we have no more configuration entries loaded\n    assert len(ac) == 0\n\n    #\n    # Test buffer handling (and overflow)\n    t = tmpdir.mkdir(\"buffer-handling\").join(\"apprise\")\n    buf = \"gnome://\"\n    t.write(buf)\n\n    # Reset our config object\n    ac.clear()\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # update our length to be the size of our actual file\n    ac[0].max_buffer_size = len(buf)\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n\n    assert len(ac.servers()) == 1\n\n    # update our buffer size to be slightly smaller then what we allow\n    ac[0].max_buffer_size = len(buf) - 1\n\n    # Content is automatically cached; so even though we adjusted the buffer\n    # above, our results have been cached so we get a 1 response.\n    assert len(ac.servers()) == 1\n\n\ndef test_apprise_multi_config_entries(tmpdir):\n    \"\"\"\n    API: AppriseConfig basic multi-adding functionality\n\n    \"\"\"\n    # temporary file to work with\n    t = tmpdir.mkdir(\"apprise-multi-add\").join(\"apprise\")\n    buf = \"\"\"\n    good://hostname\n    \"\"\"\n    t.write(buf)\n\n    # temporary empty file to work with\n    te = tmpdir.join(\"apprise-multi-add\", \"apprise-empty\")\n    te.write(\"\")\n\n    # Define our good:// url\n    class GoodNotification(NotifyBase):\n        def __init__(self, **kwargs):\n            super().__init__(notify_format=NotifyFormat.HTML, **kwargs)\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        def url(self, **kwargs):\n            # support url()\n            return \"\"\n\n    # Store our good notification in our schema map\n    N_MGR._schema_map[\"good\"] = GoodNotification\n\n    # Create ourselves a config object\n    ac = AppriseConfig()\n\n    # There are no servers loaded\n    assert len(ac) == 0\n\n    # Support adding of muilt strings and objects:\n    assert ac.add(configs=(str(t), str(t))) is True\n    assert (\n        ac.add(configs=(ConfigFile(path=str(te)), ConfigFile(path=str(t))))\n        is True\n    )\n\n    # don't support the adding of invalid content\n    assert ac.add(configs=(object(), object())) is False\n    assert ac.add(configs=object()) is False\n\n    # Try to pop an element out of range\n    with pytest.raises(IndexError):\n        ac.server_pop(len(ac.servers()))\n\n    # Pop our elements\n    while len(ac.servers()) > 0:\n        assert isinstance(ac.server_pop(len(ac.servers()) - 1), NotifyBase)\n\n\ndef test_apprise_add_config():\n    \"\"\"API AppriseConfig.add_config()\"\"\"\n    content = \"\"\"\n    # A comment line over top of a URL\n    mailto://usera:pass@gmail.com\n\n    # A line with mulitiple tag assignments to it\n    taga,tagb=gnome://\n\n    # Event if there is accidental leading spaces, this configuation\n    # is accepting of htat and will not exclude them\n                tagc=kde://\n\n    # A very poorly structured url\n    sns://:@/\n\n    # Just 1 token provided causes exception\n    sns://T1JJ3T3L2/\n    \"\"\"\n    # Create ourselves a config object\n    ac = AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n    assert ac[0].config_format is ConfigFormat.TEXT\n\n    # Object can be directly checked as a boolean; response is True\n    # when there is at least one entry\n    assert ac\n\n    # We should be able to read our 3 servers from that\n    assert len(ac.servers()) == 3\n\n    # Get our URL back\n    assert isinstance(ac[0].url(), str)\n\n    # Test invalid content\n    assert ac.add_config(content=object()) is False\n    assert ac.add_config(content=42) is False\n    assert ac.add_config(content=None) is False\n\n    # Still only one server loaded\n    assert len(ac) == 1\n\n    # Test having a pre-defined asset object and tag created\n    assert (\n        ac.add_config(content=content, asset=AppriseAsset(), tag=\"a\") is True\n    )\n\n    # Now there are 2 servers loaded\n    assert len(ac) == 2\n\n    # and 6 urls.. (as we've doubled up)\n    assert len(ac.servers()) == 6\n\n    content = \"\"\"\n    # A YAML File\n    urls:\n       - mailto://usera:pass@gmail.com\n       - gnome://:\n          tag: taga,tagb\n\n       - json://localhost:\n          +HeaderEntry1: 'a header entry'\n          -HeaderEntryDepricated: 'a deprecated entry'\n          :HeaderEntryIgnored: 'an ignored header entry'\n\n       - xml://localhost:\n          +HeaderEntry1: 'a header entry'\n          -HeaderEntryDepricated: 'a deprecated entry'\n          :HeaderEntryIgnored: 'an ignored header entry'\n    \"\"\"\n\n    # Create ourselves a config object\n    ac = AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    # One configuration file should have been found\n    assert len(ac) == 1\n    assert ac[0].config_format is ConfigFormat.YAML\n\n    # Object can be directly checked as a boolean; response is True\n    # when there is at least one entry\n    assert ac\n\n    # We should be able to read our 4 servers from that\n    assert len(ac.servers()) == 4\n\n    # Now an invalid configuration file\n    content = \"invalid\"\n\n    # Create ourselves a config object\n    ac = AppriseConfig()\n    assert ac.add_config(content=content) is False\n\n    # Nothing is loaded\n    assert len(ac.servers()) == 0\n\n\ndef test_apprise_config_tagging(tmpdir):\n    \"\"\"\n    API: AppriseConfig tagging\n\n    \"\"\"\n\n    # temporary file to work with\n    t = tmpdir.mkdir(\"tagging\").join(\"apprise\")\n    buf = \"gnome://\"\n    t.write(buf)\n\n    # Create ourselves a config object\n    ac = AppriseConfig()\n\n    # Add an item associated with tag a\n    assert ac.add(configs=str(t), asset=AppriseAsset(), tag=\"a\") is True\n    # Add an item associated with tag b\n    assert ac.add(configs=str(t), asset=AppriseAsset(), tag=\"b\") is True\n    # Add an item associated with tag a or b\n    assert ac.add(configs=str(t), asset=AppriseAsset(), tag=\"a,b\") is True\n\n    # Now filter: a:\n    assert len(ac.servers(tag=\"a\")) == 2\n    # Now filter: a or b:\n    assert len(ac.servers(tag=\"a,b\")) == 3\n    # Now filter: a and b\n    assert len(ac.servers(tag=[(\"a\", \"b\")])) == 1\n    # all matches everything\n    assert len(ac.servers(tag=\"all\")) == 3\n\n    # Test cases using the `always` keyword\n    # Create ourselves a config object\n    ac = AppriseConfig()\n\n    # Add an item associated with tag a\n    assert ac.add(configs=str(t), asset=AppriseAsset(), tag=\"a,always\") is True\n    # Add an item associated with tag b\n    assert ac.add(configs=str(t), asset=AppriseAsset(), tag=\"b\") is True\n    # Add an item associated with tag a or b\n    assert ac.add(configs=str(t), asset=AppriseAsset(), tag=\"c,d\") is True\n\n    # Now filter: a:\n    assert len(ac.servers(tag=\"a\")) == 1\n    # Now filter: a or b:\n    assert len(ac.servers(tag=\"a,b\")) == 2\n    # Now filter: e\n    # we'll match the `always'\n    assert len(ac.servers(tag=\"e\")) == 1\n    assert len(ac.servers(tag=\"e\", match_always=False)) == 0\n    # all matches everything\n    assert len(ac.servers(tag=\"all\")) == 3\n\n    # Now filter: d\n    # we'll match the `always' tag\n    assert len(ac.servers(tag=\"d\")) == 2\n    assert len(ac.servers(tag=\"d\", match_always=False)) == 1\n\n\ndef test_apprise_config_instantiate():\n    \"\"\"\n    API: AppriseConfig.instantiate()\n\n    \"\"\"\n    assert (\n        AppriseConfig.instantiate(\"file://?\", suppress_exceptions=True) is None\n    )\n\n    assert (\n        AppriseConfig.instantiate(\"invalid://?\", suppress_exceptions=True)\n        is None\n    )\n\n    class BadConfig(ConfigBase):\n        # always allow incusion\n        allow_cross_includes = ContentIncludeMode.ALWAYS\n\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n            # We fail whenever we're initialized\n            raise TypeError()\n\n        @staticmethod\n        def parse_url(url, *args, **kwargs):\n            # always parseable\n            return ConfigBase.parse_url(url, verify_host=False)\n\n    # Store our bad configuration in our schema map\n    C_MGR[\"bad\"] = BadConfig\n\n    with pytest.raises(TypeError):\n        AppriseConfig.instantiate(\"bad://path\", suppress_exceptions=False)\n\n    # Same call but exceptions suppressed\n    assert (\n        AppriseConfig.instantiate(\"bad://path\", suppress_exceptions=True)\n        is None\n    )\n\n\ndef test_invalid_apprise_config(tmpdir):\n    \"\"\"Parse invalid configuration includes.\"\"\"\n\n    class BadConfig(ConfigBase):\n        # always allow incusion\n        allow_cross_includes = ContentIncludeMode.ALWAYS\n\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n            # We intentionally fail whenever we're initialized\n            raise TypeError()\n\n        @staticmethod\n        def parse_url(url, *args, **kwargs):\n            # always parseable\n            return ConfigBase.parse_url(url, verify_host=False)\n\n    # Store our bad configuration in our schema map\n    C_MGR[\"bad\"] = BadConfig\n\n    # temporary file to work with\n    t = tmpdir.mkdir(\"apprise-bad-obj\").join(\"invalid\")\n    buf = f\"\"\"\n    # Include an invalid schema\n    include invalid://\n\n    # An unparsable valid schema\n    include https://\n\n    # A valid configuration that will throw an exception\n    include bad://\n\n    # Include ourselves (So our recursive includes fails as well)\n    include {t!s}\n\n    \"\"\"\n    t.write(buf)\n\n    # Create ourselves a config object with caching disbled\n    ac = AppriseConfig(recursion=2, insecure_includes=True, cache=False)\n\n    # Nothing loaded yet\n    assert len(ac) == 0\n\n    # Add our config\n    assert ac.add(configs=str(t), asset=AppriseAsset()) is True\n\n    # One configuration file\n    assert len(ac) == 1\n\n    # All of the servers were invalid and would not load\n    assert len(ac.servers()) == 0\n\n\ndef test_apprise_config_with_apprise_obj(tmpdir):\n    \"\"\"\n    API: ConfigBase - parse valid config\n\n    \"\"\"\n\n    # temporary file to work with\n    t = tmpdir.mkdir(\"apprise-obj\").join(\"apprise\")\n    buf = \"\"\"\n    good://hostname\n    localhost=good://localhost\n    \"\"\"\n    t.write(buf)\n\n    # Define our good:// url\n    class GoodNotification(NotifyBase):\n        def __init__(self, **kwargs):\n            super().__init__(notify_format=NotifyFormat.HTML, **kwargs)\n\n        def notify(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        def url(self, **kwargs):\n            # support url()\n            return \"\"\n\n    # Store our good notification in our schema map\n    N_MGR._schema_map[\"good\"] = GoodNotification\n\n    # Create ourselves a config object\n    ac = AppriseConfig(cache=False)\n\n    # Nothing loaded yet\n    assert len(ac) == 0\n\n    # Add an item associated with tag a\n    assert ac.add(configs=str(t), asset=AppriseAsset(), tag=\"a\") is True\n\n    # One configuration file\n    assert len(ac) == 1\n\n    # 2 services found in it\n    assert len(ac.servers()) == 2\n\n    # Pop one of them (at index 0)\n    ac.server_pop(0)\n\n    # Verify that it no longer listed\n    assert len(ac.servers()) == 1\n\n    # Test our ability to add Config objects to our apprise object\n    a = Apprise()\n\n    # Add our configuration object\n    assert a.add(servers=ac) is True\n\n    # Detect our 1 entry (originally there were 2 but we deleted one)\n    assert len(a) == 1\n\n    # Notify our service\n    assert a.notify(body=\"apprise configuration power!\") is True\n\n    # Add our configuration object\n    assert (\n        a.add(servers=[AppriseConfig(str(t)), AppriseConfig(str(t))]) is True\n    )\n\n    # Detect our 5 loaded entries now; 1 from first config, and another\n    # 2x2 based on adding our list above\n    assert len(a) == 5\n\n    # We can't add garbage\n    assert a.add(servers=object()) is False\n    assert a.add(servers=[object(), object()]) is False\n\n    # Our length is unchanged\n    assert len(a) == 5\n\n    # reference index 0 of our list\n    ref = a[0]\n    assert isinstance(ref, NotifyBase)\n\n    # Our length is unchanged\n    assert len(a) == 5\n\n    # pop the index\n    ref_popped = a.pop(0)\n\n    # Verify our response\n    assert isinstance(ref_popped, NotifyBase)\n\n    # Our length drops by 1\n    assert len(a) == 4\n\n    # Content popped is the same as one referenced by index\n    # earlier\n    assert ref == ref_popped\n\n    # pop an index out of range\n    with pytest.raises(IndexError):\n        a.pop(len(a))\n\n    # Our length remains unchanged\n    assert len(a) == 4\n\n    # Reference content out of range\n    with pytest.raises(IndexError):\n        a[len(a)]\n\n    # reference index at the end of our list\n    ref = a[len(a) - 1]\n\n    # Verify our response\n    assert isinstance(ref, NotifyBase)\n\n    # Our length stays the same\n    assert len(a) == 4\n\n    # We can pop from the back of the list without a problem too\n    ref_popped = a.pop(len(a) - 1)\n\n    # Verify our response\n    assert isinstance(ref_popped, NotifyBase)\n\n    # Content popped is the same as one referenced by index\n    # earlier\n    assert ref == ref_popped\n\n    # Our length drops by 1\n    assert len(a) == 3\n\n    # Now we'll test adding another element to the list so that it mixes up\n    # our response object.\n    # Below we add 3 different types, a ConfigBase, NotifyBase, and URL\n    assert (\n        a.add(\n            servers=[\n                ConfigFile(path=(str(t))),\n                \"good://another.host\",\n                GoodNotification(**{\"host\": \"nuxref.com\"}),\n            ]\n        )\n        is True\n    )\n\n    # Our length increases by 4 (2 entries in the config file, + 2 others)\n    assert len(a) == 7\n\n    # reference index at the end of our list\n    ref = a[len(a) - 1]\n\n    # Verify our response\n    assert isinstance(ref, NotifyBase)\n\n    # We can pop from the back of the list without a problem too\n    ref_popped = a.pop(len(a) - 1)\n\n    # Verify our response\n    assert isinstance(ref_popped, NotifyBase)\n\n    # Content popped is the same as one referenced by index\n    # earlier\n    assert ref == ref_popped\n\n    # Our length drops by 1\n    assert len(a) == 6\n\n    # pop our list\n    while len(a) > 0:\n        assert isinstance(a.pop(len(a) - 1), NotifyBase)\n\n\ndef test_recursive_config_inclusion(tmpdir):\n    \"\"\"\n    API: Apprise() Recursive Config Inclusion\n\n    \"\"\"\n\n    # To test our config classes, we make three dummy configs\n    class ConfigCrossPostAlways(ConfigFile):\n        \"\"\"A dummy config that is set to always allow inclusion.\"\"\"\n\n        service_name = \"always\"\n\n        # protocol\n        protocol = \"always\"\n\n        # Always type\n        allow_cross_includes = ContentIncludeMode.ALWAYS\n\n    class ConfigCrossPostStrict(ConfigFile):\n        \"\"\"A dummy config that is set to strict inclusion.\"\"\"\n\n        service_name = \"strict\"\n\n        # protocol\n        protocol = \"strict\"\n\n        # Always type\n        allow_cross_includes = ContentIncludeMode.STRICT\n\n    class ConfigCrossPostNever(ConfigFile):\n        \"\"\"A dummy config that is set to never allow inclusion.\"\"\"\n\n        service_name = \"never\"\n\n        # protocol\n        protocol = \"never\"\n\n        # Always type\n        allow_cross_includes = ContentIncludeMode.NEVER\n\n    # store our entries\n    C_MGR[\"never\"] = ConfigCrossPostNever\n    C_MGR[\"strict\"] = ConfigCrossPostStrict\n    C_MGR[\"always\"] = ConfigCrossPostAlways\n\n    # Make our new path valid\n    suite = tmpdir.mkdir(\"apprise_config_recursion\")\n\n    cfg01 = suite.join(\"cfg01.cfg\")\n    cfg02 = suite.mkdir(\"dir1\").join(\"cfg02.cfg\")\n    cfg03 = suite.mkdir(\"dir2\").join(\"cfg03.cfg\")\n    cfg04 = suite.mkdir(\"dir3\").join(\"cfg04.cfg\")\n\n    # Populate our files with valid configuration include lines\n    cfg01.write(f\"\"\"\n# json entry\njson://localhost:8080\n\n# absolute path inclusion to ourselves\ninclude {cfg01!s}\"\"\")\n\n    cfg02.write(\"\"\"\n# json entry\njson://localhost:8080\n\n# recursively include ourselves\ninclude cfg02.cfg\"\"\")\n\n    cfg03.write(\"\"\"\n# xml entry\nxml://localhost:8080\n\n# relative path inclusion\ninclude ../dir1/cfg02.cfg\n\n# test that we can't include invalid entries\ninclude invalid://entry\n\n# Include non includable type\ninclude memory://\"\"\")\n\n    cfg04.write(f\"\"\"\n# xml entry\nxml://localhost:8080\n\n# always include of our file\ninclude always://{cfg04!s}\n\n# never include of our file\ninclude never://{cfg04!s}\n\n# strict include of our file\ninclude strict://{cfg04!s}\"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig()\n\n    # There are no servers loaded\n    assert len(ac) == 0\n\n    # load our configuration\n    assert ac.add(configs=str(cfg01)) is True\n\n    # verify it loaded\n    assert len(ac) == 1\n\n    # 1 service will be loaded as there is no recursion at this point\n    assert len(ac.servers()) == 1\n\n    # Create ourselves a config object\n    ac = AppriseConfig(recursion=1)\n\n    # load our configuration\n    assert ac.add(configs=str(cfg01)) is True\n\n    # verify one configuration file loaded however since it recursively\n    # loaded itself 1 more time, it still doesn't impact the load count:\n    assert len(ac) == 1\n\n    # 2 services loaded now that we loaded the same file twice\n    assert len(ac.servers()) == 2\n\n    #\n    # Now we test relative file inclusion\n    #\n\n    # Create ourselves a config object\n    ac = AppriseConfig(recursion=10)\n\n    # There are no servers loaded\n    assert len(ac) == 0\n\n    # load our configuration\n    assert ac.add(configs=str(cfg02)) is True\n\n    # verify it loaded\n    assert len(ac) == 1\n\n    # 11 services loaded because we reloaded ourselves 10 times\n    # after loading the first entry\n    assert len(ac.servers()) == 11\n\n    # Test our include modes (strict, always, and never)\n\n    # Create ourselves a config object\n    ac = AppriseConfig(recursion=1)\n\n    # There are no servers loaded\n    assert len(ac) == 0\n\n    # load our configuration\n    assert ac.add(configs=str(cfg04)) is True\n\n    # verify it loaded\n    assert len(ac) == 1\n\n    # 2 servers loaded\n    # 1 - from the file read (which is set at mode STRICT\n    # 1 - from the always://\n    #\n    # The never:// can ever be includeed, and the strict:// is ot of type\n    #  file:// (the one doing the include) so it is also ignored.\n    #\n    # By turning on the insecure_includes, we can include the strict files too\n    assert len(ac.servers()) == 2\n\n    # Create ourselves a config object\n    ac = AppriseConfig(recursion=1, insecure_includes=True)\n\n    # There are no servers loaded\n    assert len(ac) == 0\n\n    # load our configuration\n    assert ac.add(configs=str(cfg04)) is True\n\n    # verify it loaded\n    assert len(ac) == 1\n\n    # 3 servers loaded\n    # 1 - from the file read (which is set at mode STRICT\n    # 1 - from the always://\n    # 1 - from the strict:// (due to insecure_includes set)\n    assert len(ac.servers()) == 3\n\n\ndef test_apprise_config_file_loading(tmpdir):\n    \"\"\"\n    API: AppriseConfig() URL Testing\n\n    \"\"\"\n\n    config_path = tmpdir / \"apprise.yml\"\n\n    # Create a temporary config file\n    config_path.write(\"urls:\\n      - json://localhost\")\n\n    # Flow from README.md\n    ap = Apprise()\n    ap.add(\"xml://localhost\")\n    config = AppriseConfig()\n    config.add(str(config_path))\n    ap.add(config)\n\n    # Using urls()\n    assert len(ap.urls()) == 2\n\n\ndef test_apprise_config_matrix_load():\n    \"\"\"\n    API: AppriseConfig() matrix initialization\n\n    \"\"\"\n\n    import apprise\n\n    class ConfigDummy(ConfigBase):\n        \"\"\"A dummy wrapper for testing the different options in the load_matrix\n        function.\"\"\"\n\n        # The default descriptive name associated with the Notification\n        service_name = \"dummy\"\n\n        # protocol as tuple\n        protocol = (\"uh\", \"oh\")\n\n        # secure protocol as tuple\n        secure_protocol = (\"no\", \"yes\")\n\n    class ConfigDummy2(ConfigBase):\n        \"\"\"A dummy wrapper for testing the different options in the load_matrix\n        function.\"\"\"\n\n        # The default descriptive name associated with the Notification\n        service_name = \"dummy2\"\n\n        # secure protocol as tuple\n        secure_protocol = (\"true\", \"false\")\n\n    class ConfigDummy3(ConfigBase):\n        \"\"\"A dummy wrapper for testing the different options in the load_matrix\n        function.\"\"\"\n\n        # The default descriptive name associated with the Notification\n        service_name = \"dummy3\"\n\n        # secure protocol as string\n        secure_protocol = \"true\"\n\n    class ConfigDummy4(ConfigBase):\n        \"\"\"A dummy wrapper for testing the different options in the load_matrix\n        function.\"\"\"\n\n        # The default descriptive name associated with the Notification\n        service_name = \"dummy4\"\n\n        # protocol as string\n        protocol = \"true\"\n\n    # Generate ourselves a fake entry\n    apprise.config.ConfigDummy = ConfigDummy\n    apprise.config.ConfigDummy2 = ConfigDummy2\n    apprise.config.ConfigDummy3 = ConfigDummy3\n    apprise.config.ConfigDummy4 = ConfigDummy4\n\n\ndef test_configmatrix_dynamic_importing(tmpdir):\n    \"\"\"\n    API: Apprise() Config Matrix Importing\n\n    \"\"\"\n\n    # Make our new path valid\n    suite = tmpdir.mkdir(\"apprise_config_test_suite\")\n    suite.join(\"__init__.py\").write(\"\")\n\n    module_name = \"badconfig\"\n\n    # Update our path to point to our new test suite\n    sys.path.insert(0, str(suite))\n\n    # Create a base area to work within\n    base = suite.mkdir(module_name)\n    base.join(\"__init__.py\").write(\"\")\n\n    # Test no app_id\n    base.join(\"ConfigBadFile1.py\").write(\"\"\"\nclass ConfigBadFile1:\n    pass\"\"\")\n\n    # No class of the same name\n    base.join(\"ConfigBadFile2.py\").write(\"\"\"\nclass BadClassName:\n    pass\"\"\")\n\n    # Exception thrown\n    base.join(\"ConfigBadFile3.py\").write(\"\"\"raise ImportError()\"\"\")\n\n    # Utilizes a schema:// already occupied (as string)\n    base.join(\"ConfigGoober.py\").write(\"\"\"\nfrom apprise.config import ConfigBase\nclass ConfigGoober(ConfigBase):\n    # This class tests the fact we have a new class name, but we're\n    # trying to over-ride items previously used\n\n    # The default simple (insecure) protocol (used by ConfigHTTP)\n    protocol = ('http', 'goober')\n\n    # The default secure protocol (used by ConfigHTTP)\n    secure_protocol = 'https'\n\n    @staticmethod\n    def parse_url(url, *args, **kwargs):\n        # always parseable\n        return ConfigBase.parse_url(url, verify_host=False)\"\"\")\n\n    # Utilizes a schema:// already occupied (as tuple)\n    base.join(\"ConfigBugger.py\").write(\"\"\"\nfrom apprise.config import ConfigBase\nclass ConfigBugger(ConfigBase):\n    # This class tests the fact we have a new class name, but we're\n    # trying to over-ride items previously used\n\n    # The default simple (insecure) protocol (used by ConfigHTTP), the other\n    # isn't\n    protocol = ('http', 'bugger-test' )\n\n    # The default secure protocol (used by ConfigHTTP), the other isn't\n    secure_protocol = ('https', ['garbage'])\n\n    @staticmethod\n    def parse_url(url, *args, **kwargs):\n        # always parseable\n        return ConfigBase.parse_url(url, verify_host=False)\"\"\")\n\n\n@mock.patch(\"os.path.getsize\")\ndef test_config_base_parse_inaccessible_text_file(mock_getsize, tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_inaccessible_text_file\n\n    \"\"\"\n\n    # temporary file to work with\n    t = tmpdir.mkdir(\"inaccessible\").join(\"apprise\")\n    buf = \"gnome://\"\n    t.write(buf)\n\n    # Set getsize return value\n    mock_getsize.return_value = None\n    mock_getsize.side_effect = OSError\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # The following internally throws an exception but still counts\n    # as a loaded configuration file\n    assert len(ac) == 1\n\n    # Thus no notifications are loaded\n    assert len(ac.servers()) == 0\n\n\ndef test_config_base_parse_yaml_file01(tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_yaml_file (#1)\n\n    \"\"\"\n    t = tmpdir.mkdir(\"empty-file\").join(\"apprise.yml\")\n    t.write(\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # no notifications are loaded\n    assert len(ac.servers()) == 0\n\n\ndef test_config_base_parse_yaml_file02(tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_yaml_file (#2)\n\n    \"\"\"\n    t = tmpdir.mkdir(\"matching-tags\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:\n    - tag: test1\n  - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn:\n    - tag: test2\n  - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb:\n    - tag: test3\"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # no notifications are loaded\n    assert len(ac.servers()) == 3\n\n    # Test our ability to add Config objects to our apprise object\n    a = Apprise()\n\n    # Add our configuration object\n    assert a.add(servers=ac) is True\n\n    # Detect our 3 entry as they should have loaded successfully\n    assert len(a) == 3\n\n    # No match\n    assert sum(1 for _ in a.find(\"no-match\")) == 0\n    # Match everything\n    assert sum(1 for _ in a.find(\"all\")) == 3\n    # Match test1 entry\n    assert sum(1 for _ in a.find(\"test1\")) == 1\n    # Match test2 entry\n    assert sum(1 for _ in a.find(\"test2\")) == 1\n    # Match test3 entry\n    assert sum(1 for _ in a.find(\"test3\")) == 1\n    # Match test1 or test3 entry\n    assert sum(1 for _ in a.find(\"test1, test3\")) == 2\n\n\ndef test_config_base_parse_yaml_file03(tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_yaml_file (#3)\n\n    \"\"\"\n\n    t = tmpdir.mkdir(\"bad-first-entry\").join(\"apprise.yml\")\n    # The first entry is -tag and not <dash><space>tag\n    # The element is therefore not picked up; This causes us to display\n    # some warning messages to the screen complaining of this typo yet\n    # still allowing us to load the URL since it is valid\n    t.write(\"\"\"urls:\n  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:\n    -tag: test1\n  - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn:\n    - tag: test2\n  - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb:\n    - tag: test3\"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # no notifications lines processed is 3\n    assert len(ac.servers()) == 3\n\n    # Test our ability to add Config objects to our apprise object\n    a = Apprise()\n\n    # Add our configuration object\n    assert a.add(servers=ac) is True\n\n    # Detect our 3 entry as they should have loaded successfully\n    assert len(a) == 3\n\n    # No match\n    assert sum(1 for _ in a.find(\"no-match\")) == 0\n    # Match everything\n    assert sum(1 for _ in a.find(\"all\")) == 3\n    # No match for bad entry\n    assert sum(1 for _ in a.find(\"test1\")) == 0\n    # Match test2 entry\n    assert sum(1 for _ in a.find(\"test2\")) == 1\n    # Match test3 entry\n    assert sum(1 for _ in a.find(\"test3\")) == 1\n    # Match test1 or test3 entry; (only matches test3)\n    assert sum(1 for _ in a.find(\"test1, test3\")) == 1\n\n\ndef test_config_base_parse_yaml_file04(tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_yaml_file (#4)\n\n    Test the always keyword\n\n    \"\"\"\n    t = tmpdir.mkdir(\"always-keyword\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:\n    - tag: test1,always\n  - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn:\n    - tag: test2\n  - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb:\n    - tag: test3\"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # no notifications are loaded\n    assert len(ac.servers()) == 3\n\n    # Test our ability to add Config objects to our apprise object\n    a = Apprise()\n\n    # Add our configuration object\n    assert a.add(servers=ac) is True\n\n    # Detect our 3 entry as they should have loaded successfully\n    assert len(a) == 3\n\n    # No match still matches `always` keyword\n    assert sum(1 for _ in a.find(\"no-match\")) == 1\n    # Unless we explicitly do not look for that file\n    assert sum(1 for _ in a.find(\"no-match\", match_always=False)) == 0\n    # Match everything\n    assert sum(1 for _ in a.find(\"all\")) == 3\n    # Match test1 entry (also has `always` keyword\n    assert sum(1 for _ in a.find(\"test1\")) == 1\n    assert sum(1 for _ in a.find(\"test1\", match_always=False)) == 1\n    # Match test2 entry (and test1 due to always keyword)\n    assert sum(1 for _ in a.find(\"test2\")) == 2\n    assert sum(1 for _ in a.find(\"test2\", match_always=False)) == 1\n    # Match test3 entry (and test1 due to always keyword)\n    assert sum(1 for _ in a.find(\"test3\")) == 2\n    assert sum(1 for _ in a.find(\"test3\", match_always=False)) == 1\n    # Match test1 or test3 entry\n    assert sum(1 for _ in a.find(\"test1, test3\")) == 2\n\n\ndef test_apprise_config_template_parse(tmpdir):\n    \"\"\"\n    API: AppriseConfig parsing of templates\n\n    \"\"\"\n\n    # Create ourselves a config object\n    ac = AppriseConfig()\n\n    t = tmpdir.mkdir(\"template-testing\").join(\"apprise.yml\")\n    t.write(\"\"\"\n\n    tag:\n      - company\n\n    # A comment line over top of a URL\n    urls:\n       - mailto://user:pass@example.com:\n          - to: user1@gmail.com\n            cc: test@hotmail.com\n\n          - to: user2@gmail.com\n            tag: co-worker\n    \"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # 2 emails to be sent\n    assert len(ac.servers()) == 2\n\n    # The below checks are very customized for NotifyMail but just\n    # test that the content got passed correctly\n    assert (False, \"user1@gmail.com\") in ac[0][0].targets\n    assert \"test@hotmail.com\" in ac[0][0].cc\n    assert \"company\" in ac[0][1].tags\n\n    assert (False, \"user2@gmail.com\") in ac[0][1].targets\n    assert \"company\" in ac[0][1].tags\n    assert \"co-worker\" in ac[0][1].tags\n\n    #\n    # Specifically test _special_token_handler()\n    #\n    tokens = {\n        # This maps to itself (bcc); no change here\n        \"bcc\": \"user@test.com\",\n        # This should get mapped to 'targets'\n        \"to\": \"user1@abc.com\",\n        # white space and tab is intentionally added to the end to verify we\n        # do not play/tamper with information\n        \"targets\": \"user2@abc.com, user3@abc.com   \\t\",\n        # If the end user provides a configuration for data we simply don't use\n        # this isn't a proble... we simply don't touch it either; we leave it\n        # as is.\n        \"ignore\": \"not-used\",\n    }\n\n    result = ConfigBase._special_token_handler(\"mailto\", tokens)\n    # to gets mapped to targets\n    assert \"to\" not in result\n\n    # bcc is allowed here\n    assert \"bcc\" in result\n    assert \"targets\" in result\n    # Not used, but also not touched; this entry should still be in our result\n    # set\n    assert \"ignore\" in result\n    # We'll concatinate all of our targets together\n    assert len(result[\"targets\"]) == 2\n    assert \"user1@abc.com\" in result[\"targets\"]\n    # Content is passed as is\n    assert \"user2@abc.com, user3@abc.com   \\t\" in result[\"targets\"]\n\n    # We re-do the simmiar test above.  The very key difference is the\n    # `targets` is a list already (it's expected type) so `to` can properly be\n    # concatinated into the list vs the above (which tries to correct the\n    # situation)\n    tokens = {\n        # This maps to itself (bcc); no change here\n        \"bcc\": \"user@test.com\",\n        # This should get mapped to 'targets'\n        \"to\": \"user1@abc.com\",\n        # similar to the above test except targets is now a proper\n        # dictionary allowing the `to` (when translated to `targets`) to get\n        # appended to it\n        \"targets\": [\"user2@abc.com\", \"user3@abc.com\"],\n        # If the end user provides a configuration for data we simply don't use\n        # this isn't a proble... we simply don't touch it either; we leave it\n        # as is.\n        \"ignore\": \"not-used\",\n    }\n\n    result = ConfigBase._special_token_handler(\"mailto\", tokens)\n    # to gets mapped to targets\n    assert \"to\" not in result\n\n    # bcc is allowed here\n    assert \"bcc\" in result\n    assert \"targets\" in result\n    # Not used, but also not touched; this entry should still be in our result\n    # set\n    assert \"ignore\" in result\n\n    # Now we'll see the new user added as expected (concatinated into our list)\n    assert len(result[\"targets\"]) == 3\n    assert \"user1@abc.com\" in result[\"targets\"]\n    assert \"user2@abc.com\" in result[\"targets\"]\n    assert \"user3@abc.com\" in result[\"targets\"]\n\n    # Test providing a list\n    t.write(\"\"\"\n    # A comment line over top of a URL\n    urls:\n       - mailtos://user:pass@example.com:\n          - smtp: smtp3-dev.google.gmail.com\n            to:\n              - John Smith <user1@gmail.com>\n              - Jason Tater <user2@gmail.com>\n              - user3@gmail.com\n\n          - to: Henry Fisher <user4@gmail.com>, Jason Archie <user5@gmail.com>\n            smtp_host: smtp5-dev.google.gmail.com\n            tag: drinking-buddy\n\n       # provide case where the URL includes some input too\n       # In both of these cases, the cc and targets (to) get over-ridden\n       # by values below\n       - mailtos://user:pass@example.com/arnold@imdb.com/?cc=bill@micro.com/:\n            to:\n              - override01@gmail.com\n            cc:\n              - override02@gmail.com\n\n       - sinch://:\n          - spi: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n            token: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n\n            # Test a case where we expect a string, but yaml reads it in as\n            # a number\n            from: 10005243890\n            to: +1(123)555-1234\n    \"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # 2 emails to be sent and 1 Sinch service call\n    assert len(ac.servers()) == 4\n\n    # Verify our users got placed into the to\n    assert len(ac[0][0].targets) == 3\n    assert (\"John Smith\", \"user1@gmail.com\") in ac[0][0].targets\n    assert (\"Jason Tater\", \"user2@gmail.com\") in ac[0][0].targets\n    assert (False, \"user3@gmail.com\") in ac[0][0].targets\n    assert ac[0][0].smtp_host == \"smtp3-dev.google.gmail.com\"\n\n    assert len(ac[0][1].targets) == 2\n    assert (\"Henry Fisher\", \"user4@gmail.com\") in ac[0][1].targets\n    assert (\"Jason Archie\", \"user5@gmail.com\") in ac[0][1].targets\n    assert \"drinking-buddy\" in ac[0][1].tags\n    assert ac[0][1].smtp_host == \"smtp5-dev.google.gmail.com\"\n\n    # Our third test tests cases where some variables are defined inline\n    # and additional ones are defined below that share the same token space\n    assert len(ac[0][2].targets) == 1\n    assert len(ac[0][2].cc) == 1\n    assert (False, \"override01@gmail.com\") in ac[0][2].targets\n    assert \"override02@gmail.com\" in ac[0][2].cc\n\n    # Test our Since configuration now:\n    assert len(ac[0][3].targets) == 1\n    assert ac[0][3].service_plan_id == \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n    assert ac[0][3].source == \"+10005243890\"\n    assert ac[0][3].targets[0] == \"+11235551234\"\n\n\ndef test_config_base_yaml_trace_logging(tmpdir):\n    \"\"\"\n    ConfigBase: cover TRACE-only logging path in YAML parsing\n\n    This specifically targets:\n      if ConfigBase.logger.isEnabledFor(logging.TRACE):\n          ConfigBase.logger.trace(...)\n    \"\"\"\n    t = tmpdir.mkdir(\"trace-logging\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n  - json://localhost\n\"\"\")\n\n    # Patch logger methods to force and observe TRACE logging behaviour\n    with mock.patch.object(\n        ConfigBase.logger,\n        \"isEnabledFor\",\n        side_effect=lambda level: level == logging.TRACE,\n    ), mock.patch.object(ConfigBase.logger, \"trace\") as m_trace:\n        ac = AppriseConfig(paths=str(t))\n        assert len(ac) == 1\n        assert len(ac.servers()) == 1\n\n        # We expect our TRACE log call to have happened at least once\n        assert m_trace.called is True\n\n\ndef test_config_base_clear_cache(tmpdir):\n    \"\"\"\n    ConfigBase: cover clear_cache() behaviour\n    \"\"\"\n    t = tmpdir.mkdir(\"clear-cache\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n  - json://localhost\n\"\"\")\n\n    ac = AppriseConfig(paths=str(t))\n\n    # Trigger a load to populate cache inside the config instance\n    assert len(ac.servers()) == 1\n\n    # The underlying config object should have cached state\n    cfg = ac[0]\n    assert isinstance(cfg, ConfigBase)\n    assert isinstance(getattr(cfg, \"_cached_servers\", None), list)\n    assert getattr(cfg, \"_cached_time\", None) is not None\n\n    # Now clear it and confirm it resets as expected\n    cfg.clear_cache()\n    assert getattr(cfg, \"_cached_servers\", None) is None\n    assert getattr(cfg, \"_cached_time\", None) is None\n\n\ndef test_config_base_parse_url_cache_variants():\n    \"\"\"\n    ConfigBase.parse_url(): cover cache parsing for int and bool values\n    \"\"\"\n    # Integer cache value\n    r = ConfigBase.parse_url(\"file:///tmp/apprise.yml?cache=60\")\n    assert isinstance(r, dict)\n    assert r.get(\"cache\") == 60\n\n    # Boolean cache value (non-int)\n    r = ConfigBase.parse_url(\"file:///tmp/apprise.yml?cache=no\")\n    assert isinstance(r, dict)\n    assert r.get(\"cache\") is False\n\n    r = ConfigBase.parse_url(\"file:///tmp/apprise.yml?cache=yes\")\n    assert isinstance(r, dict)\n    assert r.get(\"cache\") is True\n\n\ndef test_config_base_parse_url_invalid_format_removed():\n    \"\"\"\n    ConfigBase.parse_url(): cover invalid format handling (format removed)\n    \"\"\"\n    r = ConfigBase.parse_url(\n        \"file:///tmp/apprise.yml?format=definitely-not-a-format\")\n    assert isinstance(r, dict)\n\n    # Invalid format should be dropped from results\n    assert \"format\" not in r\n\n\ndef test_config_base_expired_with_int_cache(monkeypatch):\n    \"\"\"\n    ConfigBase.expired(): cover int cache expiry checks\n\n    Simulate time movement to ensure both non-expired and expired paths.\n    \"\"\"\n    cb = ConfigBase(cache=30)\n\n    # Seed cache\n    cb._cached_servers = []\n    cb._cached_time = 1000.0\n\n    # Within cache window\n    monkeypatch.setattr(time, \"time\", lambda: 1020.0)\n    assert cb.expired() is False\n\n    # Beyond cache window\n    monkeypatch.setattr(time, \"time\", lambda: 1031.0)\n    assert cb.expired() is True\n"
  },
  {
    "path": "tests/test_apprise_emojis.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport sys\n\nfrom apprise import emojis\n\nlogging.disable(logging.CRITICAL)\n\n# Ensure we don't create .pyc files for these tests\nsys.dont_write_bytecode = True\n\n\ndef test_emojis():\n    \"emojis: apply_emojis() testing\"\n\n    assert emojis.apply_emojis(\"\") == \"\"\n    assert emojis.apply_emojis(\"no change\") == \"no change\"\n    assert emojis.apply_emojis(\":smile:\") == \"😄\"\n    assert emojis.apply_emojis(\":smile::smile:\") == \"😄😄\"\n\n    # Missing Delimiters\n    assert emojis.apply_emojis(\":smile\") == \":smile\"\n    assert emojis.apply_emojis(\"smile:\") == \"smile:\"\n\n    # Bad data\n    assert emojis.apply_emojis(None) == \"\"\n    assert emojis.apply_emojis(object) == \"\"\n    assert emojis.apply_emojis(True) == \"\"\n    assert emojis.apply_emojis(4.0) == \"\"\n"
  },
  {
    "path": "tests/test_apprise_helpers.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport sys\n\nimport helpers\n\nlogging.disable(logging.CRITICAL)\n\n# Ensure we don't create .pyc files for these tests\nsys.dont_write_bytecode = True\n\n\ndef test_environ_temporary_change():\n    \"\"\"helpers: environ() testing\"\"\"\n    # This is a helper function; but it does enough that we want to verify\n    # our usage of it works correctly; yes... we're testing a test\n\n    e_key1 = \"APPRISE_TEMP1\"\n    e_key2 = \"APPRISE_TEMP2\"\n    e_key3 = \"APPRISE_TEMP3\"\n\n    e_val1 = \"ABCD\"\n    e_val2 = \"DEFG\"\n    e_val3 = \"HIJK\"\n\n    os.environ[e_key1] = e_val1\n    os.environ[e_key2] = e_val2\n    os.environ[e_key3] = e_val3\n\n    # Ensure our environment variable stuck\n    assert e_key1 in os.environ\n    assert e_val1 in os.environ[e_key1]\n    assert e_key2 in os.environ\n    assert e_val2 in os.environ[e_key2]\n    assert e_key3 in os.environ\n    assert e_val3 in os.environ[e_key3]\n\n    with helpers.environ(e_key1, e_key3):\n        # Eliminates Environment Variable 1 and 3\n        assert e_key1 not in os.environ\n        assert e_key2 in os.environ\n        assert e_val2 in os.environ[e_key2]\n        assert e_key3 not in os.environ\n\n    # after with is over, environment is restored to normal\n    assert e_key1 in os.environ\n    assert e_val1 in os.environ[e_key1]\n    assert e_key2 in os.environ\n    assert e_val2 in os.environ[e_key2]\n    assert e_key3 in os.environ\n    assert e_val3 in os.environ[e_key3]\n\n    d_key = \"APPRISE_NOT_SET\"\n    n_key = \"APPRISE_NEW_KEY\"\n    n_val = \"NEW_VAL\"\n\n    # Verify that our temporary variables (defined above) are not pre-existing\n    # environemnt variables as we'll be setting them below\n    assert n_key not in os.environ\n    assert d_key not in os.environ\n\n    # makes it easier to pass in the arguments\n    updates = {\n        e_key1: e_val3,\n        e_key2: e_val1,\n        n_key: n_val,\n    }\n    with helpers.environ(d_key, e_key3, **updates):\n        # Attempt to eliminate an undefined key (silently ignored)\n        # Eliminates Environment Variable 3\n        # Environment Variable 1 takes on the value of Env 3\n        # Environment Variable 2 takes on the value of Env 1\n        # Set a brand new variable that previously didn't exist\n        assert e_key1 in os.environ\n        assert e_val3 in os.environ[e_key1]\n        assert e_key2 in os.environ\n        assert e_val1 in os.environ[e_key2]\n        assert e_key3 not in os.environ\n\n        # Can't delete a variable that doesn't exist; so we're in the same\n        # state here.\n        assert d_key not in os.environ\n\n        # Our temporary variables will be found now\n        assert n_key in os.environ\n        assert n_val in os.environ[n_key]\n\n    # after with is over, environment is restored to normal\n    assert e_key1 in os.environ\n    assert e_val1 in os.environ[e_key1]\n    assert e_key2 in os.environ\n    assert e_val2 in os.environ[e_key2]\n    assert e_key3 in os.environ\n    assert e_val3 in os.environ[e_key3]\n\n    # Even our temporary variables are now missing\n    assert n_key not in os.environ\n    assert d_key not in os.environ\n"
  },
  {
    "path": "tests/test_apprise_jsonencoder.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport base64\nfrom datetime import datetime, timezone\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nimport sys\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise import Apprise\nfrom apprise.common import AWARE_DATE_ISO_FORMAT, NAIVE_DATE_ISO_FORMAT\nfrom apprise.locale import LazyTranslation\nfrom apprise.utils.json import AppriseJSONEncoder\n\nlogging.disable(logging.CRITICAL)\n\n# Ensure we don't create .pyc files for these tests\nsys.dont_write_bytecode = True\n\n\ndef test_apprise_json_encoder_datetime_naive():\n    \"AppriseJSONEncoder: naive datetime serialization\"\n\n    dt = datetime(2026, 3, 11, 12, 30, 45, 123456)\n    result = json.dumps(dt, cls=AppriseJSONEncoder)\n    assert result == f'\"{dt.strftime(NAIVE_DATE_ISO_FORMAT)}\"'\n    assert \"T\" in result\n    # Naive datetimes have no timezone offset\n    assert result.endswith('\"')\n    assert \"+00:00\" not in result\n\n\ndef test_apprise_json_encoder_datetime_aware():\n    \"AppriseJSONEncoder: aware datetime serialization\"\n\n    dt = datetime(2026, 3, 11, 12, 30, 45, 123456, tzinfo=timezone.utc)\n    result = json.dumps(dt, cls=AppriseJSONEncoder)\n    assert result == f'\"{dt.strftime(AWARE_DATE_ISO_FORMAT)}\"'\n    assert \"T\" in result\n    # Aware datetimes include the timezone offset\n    assert \"+0000\" in result\n\n\ndef test_apprise_json_encoder_bytes():\n    \"AppriseJSONEncoder: bytes serialization\"\n\n    data = b\"hello world\"\n    result = json.dumps(data, cls=AppriseJSONEncoder)\n    expected = base64.b64encode(data).decode(\"utf-8\")\n    assert result == f'\"{expected}\"'\n\n    # Empty bytes\n    result = json.dumps(b\"\", cls=AppriseJSONEncoder)\n    assert result == '\"\"'\n\n    # Binary data\n    data = bytes(range(256))\n    result = json.dumps(data, cls=AppriseJSONEncoder)\n    expected = base64.b64encode(data).decode(\"utf-8\")\n    assert result == f'\"{expected}\"'\n\n\ndef test_apprise_json_encoder_set():\n    \"AppriseJSONEncoder: set serialization\"\n\n    data = {1, 2, 3}\n    result = json.loads(json.dumps(data, cls=AppriseJSONEncoder))\n    assert isinstance(result, list)\n    assert sorted(result) == [1, 2, 3]\n\n    # Empty set\n    result = json.loads(json.dumps(set(), cls=AppriseJSONEncoder))\n    assert result == []\n\n\ndef test_apprise_json_encoder_frozenset():\n    \"AppriseJSONEncoder: frozenset serialization\"\n\n    data = frozenset([4, 5, 6])\n    result = json.loads(json.dumps(data, cls=AppriseJSONEncoder))\n    assert isinstance(result, list)\n    assert sorted(result) == [4, 5, 6]\n\n    # Empty frozenset\n    result = json.loads(json.dumps(frozenset(), cls=AppriseJSONEncoder))\n    assert result == []\n\n\ndef test_apprise_json_encoder_tuple():\n    \"AppriseJSONEncoder: tuple serialization\"\n\n    data = (7, 8, 9)\n    result = json.loads(json.dumps(data, cls=AppriseJSONEncoder))\n    assert isinstance(result, list)\n    assert result == [7, 8, 9]\n\n    # Empty tuple\n    result = json.loads(json.dumps((), cls=AppriseJSONEncoder))\n    assert result == []\n\n\ndef test_apprise_json_encoder_lazy_translation():\n    \"AppriseJSONEncoder: LazyTranslation serialization\"\n\n    lt = LazyTranslation(text=\"hello world\")\n    result = json.dumps(lt, cls=AppriseJSONEncoder)\n    assert result == '\"hello world\"'\n\n    # A translation with no gettext mapping returns the original text\n    lt = LazyTranslation(text=\"no-translation-key-xyz\")\n    result = json.dumps(lt, cls=AppriseJSONEncoder)\n    assert result == '\"no-translation-key-xyz\"'\n\n\ndef test_apprise_json_encoder_unsupported_type():\n    \"AppriseJSONEncoder: unsupported type raises TypeError\"\n\n    class CustomObj:\n        pass\n\n    with pytest.raises(TypeError):\n        json.dumps(CustomObj(), cls=AppriseJSONEncoder)\n\n\ndef test_apprise_json_encoder_nested():\n    \"AppriseJSONEncoder: nested structures with mixed types\"\n\n    data = {\n        \"when\": datetime(2026, 3, 11, 0, 0, 0, tzinfo=timezone.utc),\n        \"payload\": b\"binary data\",\n        \"tags\": {\"alpha\", \"beta\"},\n        \"label\": LazyTranslation(text=\"nested\"),\n        \"coords\": (1.0, 2.0),\n    }\n    result = json.loads(json.dumps(data, cls=AppriseJSONEncoder))\n\n    assert isinstance(result[\"when\"], str)\n    assert \"T\" in result[\"when\"]\n    assert isinstance(result[\"payload\"], str)\n    assert result[\"payload\"] == \\\n        base64.b64encode(b\"binary data\").decode(\"utf-8\")\n    assert isinstance(result[\"tags\"], list)\n    assert sorted(result[\"tags\"]) == [\"alpha\", \"beta\"]\n    assert result[\"label\"] == \"nested\"\n    assert result[\"coords\"] == [1.0, 2.0]\n\n\ndef test_apprise_json_method_no_path():\n    \"Apprise.json(): returns compact JSON string when no path is given\"\n\n    apobj = Apprise()\n\n    # Default call — compact output (indent=None), no newlines\n    result = apobj.json()\n    assert isinstance(result, str)\n    parsed = json.loads(result)\n    assert isinstance(parsed, dict)\n    # Compact: no newlines in the output\n    assert \"\\n\" not in result\n\n    # show_requirements and show_disabled flags are forwarded to details()\n    result_req = apobj.json(show_requirements=True)\n    assert isinstance(result_req, str)\n    assert json.loads(result_req)\n\n    result_dis = apobj.json(show_disabled=True)\n    assert isinstance(result_dis, str)\n    assert json.loads(result_dis)\n\n\ndef test_apprise_json_method_indent():\n    \"Apprise.json(): indent > 0 produces pretty-printed output with newlines\"\n\n    apobj = Apprise()\n\n    result = apobj.json(indent=4)\n    assert isinstance(result, str)\n    # Pretty-printed output contains newlines\n    assert \"\\n\" in result\n    assert json.loads(result)\n\n\ndef test_apprise_json_method_with_path(tmpdir):\n    \"Apprise.json(): writes JSON to file and returns True\"\n\n    apobj = Apprise()\n    output = tmpdir.join(\"details.json\")\n\n    assert apobj.json(path=str(output)) is True\n    assert output.check(file=True)\n\n    content = output.read()\n    parsed = json.loads(content)\n    assert isinstance(parsed, dict)\n\n    # Compact by default — no newlines\n    assert \"\\n\" not in content\n\n\ndef test_apprise_json_method_with_path_and_indent(tmpdir):\n    \"Apprise.json(): writes pretty-printed JSON to file when indent is set\"\n\n    apobj = Apprise()\n    output = tmpdir.join(\"details_pretty.json\")\n\n    assert apobj.json(path=str(output), indent=2) is True\n    content = output.read()\n    assert \"\\n\" in content\n    assert json.loads(content)\n\n\ndef test_apprise_json_method_write_failure(tmpdir):\n    \"Apprise.json(): returns False when json.dump raises OSError\"\n\n    apobj = Apprise()\n    output = tmpdir.join(\"fail.json\")\n\n    with mock.patch(\n            \"apprise.apprise.json.dump\", side_effect=OSError(\"disk full\")):\n        assert apobj.json(path=str(output)) is False\n\n\ndef test_apprise_json_method_write_eoferror(tmpdir):\n    \"Apprise.json(): returns False when json.dump raises EOFError\"\n\n    apobj = Apprise()\n    output = tmpdir.join(\"eof.json\")\n\n    with mock.patch(\"apprise.apprise.json.dump\", side_effect=EOFError):\n        assert apobj.json(path=str(output)) is False\n"
  },
  {
    "path": "tests/test_apprise_pickle.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport pickle\nimport sys\n\nfrom apprise import Apprise, AppriseAsset, AppriseLocale\n\nlogging.disable(logging.CRITICAL)\n\n# Ensure we don't create .pyc files for these tests\nsys.dont_write_bytecode = True\n\n\ndef test_apprise_pickle_asset(tmpdir):\n    \"\"\"pickle: AppriseAsset\"\"\"\n    asset = AppriseAsset()\n    serialized = pickle.dumps(asset)\n    new_asset = pickle.loads(serialized)\n\n    # iterate over some keys to verify they're still the same:\n    keys = (\n        \"app_id\",\n        \"app_desc\",\n        \"app_url\",\n        \"html_notify_map\",\n        \"ascii_notify_map\",\n        \"default_html_color\",\n        \"default_extension\",\n        \"theme\",\n        \"image_url_mask\",\n        \"image_url_logo\",\n        \"image_path_mask\",\n        \"body_format\",\n        \"async_mode\",\n        \"interpret_escapes\",\n        \"encoding\",\n        \"secure_logging\",\n        \"_recursion\",\n    )\n\n    for key in keys:\n        assert getattr(asset, key) == getattr(new_asset, key)\n\n\ndef test_apprise_pickle_locale(tmpdir):\n    \"\"\"pickle: AppriseLocale\"\"\"\n    locale = AppriseLocale()\n    serialized = pickle.dumps(locale)\n    new_locale = pickle.loads(serialized)\n\n    assert locale.lang == new_locale.lang\n\n    # Ensure internal functions still call in new object\n    new_locale.detect_language()\n\n\ndef test_apprise_pickle_core(tmpdir):\n    \"\"\"pickle: Apprise\"\"\"\n    asset = AppriseAsset(app_id=\"default\")\n    apobj = Apprise(asset=asset)\n\n    # Create a custom asset so we can verify it gets correctly serialized\n    xml_asset = AppriseAsset(app_id=\"xml\")\n\n    # Store our Entries\n    apobj.add(\"json://localhost\")\n    apobj.add(\"xml://localhost\", asset=xml_asset)\n    apobj.add(\"form://localhost\")\n    apobj.add(\"mailto://user:pass@localhost\", tag=\"email\")\n    serialized = pickle.dumps(apobj)\n\n    # Unserialize our object\n    new_apobj = pickle.loads(serialized)\n\n    # Verify that it loaded our URLs back\n    assert len(new_apobj) == 4\n\n    # Our assets were kept (note the XML altered entry)\n    assert apobj[0].app_id == \"default\"\n    assert apobj[1].app_id == \"xml\"\n    assert apobj[2].app_id == \"default\"\n    assert apobj[3].app_id == \"default\"\n\n    # Our tag was kept\n    assert \"email\" in apobj[3].tags\n"
  },
  {
    "path": "tests/test_apprise_translations.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport ctypes\nfrom importlib import reload\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport sys\nfrom unittest import mock\n\nfrom helpers import environ\nimport pytest\n\nfrom apprise import locale\n\nlogging.disable(logging.CRITICAL)\n\n\ndef test_apprise_trans():\n    \"\"\"\n    API: Test apprise locale object\n    \"\"\"\n    lazytrans = locale.LazyTranslation(\"Token\")\n    assert str(lazytrans) == \"Token\"\n\n\n@pytest.mark.skipif(\"gettext\" not in sys.modules, reason=\"Requires gettext\")\ndef test_apprise_trans_gettext_init():\n    \"\"\"\n    API: Handle gettext\n    \"\"\"\n    # Toggle\n    locale.GETTEXT_LOADED = False\n\n    # Objects can still be created\n    al = locale.AppriseLocale()\n\n    with al.lang_at(\"en\") as _:\n        # functions still behave as normal\n        assert _ is None\n\n    # Restore the object\n    locale.GETTEXT_LOADED = True\n\n\n@pytest.mark.skipif(\"gettext\" not in sys.modules, reason=\"Requires gettext\")\n@mock.patch(\"gettext.translation\")\n@mock.patch(\"locale.getlocale\")\ndef test_apprise_trans_gettext_translations(\n    mock_getlocale, mock_gettext_trans\n):\n    \"\"\"\n    API: Apprise() Gettext translations\n\n    \"\"\"\n\n    # Set- our gettext.locale() return value\n    mock_getlocale.return_value = (\"en_US\", \"UTF-8\")\n\n    mock_gettext_trans.side_effect = FileNotFoundError()\n\n    # This throws internally but we handle it gracefully\n    al = locale.AppriseLocale()\n\n    with al.lang_at(\"en\"):\n        # functions still behave as normal\n        pass\n\n    # This throws internally but we handle it gracefully\n    locale.AppriseLocale(language=\"fr\")\n\n\n@pytest.mark.skipif(hasattr(ctypes, \"windll\"), reason=\"Unique Nux test cases\")\n@pytest.mark.skipif(\"gettext\" not in sys.modules, reason=\"Requires gettext\")\n@mock.patch(\"locale.getlocale\")\ndef test_apprise_trans_gettext_lang_at(mock_getlocale):\n    \"\"\"\n    API: Apprise() Gettext lang_at\n\n    \"\"\"\n\n    # Set- our gettext.locale() return value\n    mock_getlocale.return_value = (\"en_CA\", \"UTF-8\")\n\n    # This throws internally but we handle it gracefully\n    al = locale.AppriseLocale()\n\n    # Edge Cases\n    assert al.add(\"en\", set_default=False) is True\n    assert al.add(\"en\", set_default=True) is True\n\n    with al.lang_at(\"en\"):\n        # functions still behave as normal\n        pass\n\n    # This throws internally but we handle it gracefully\n    locale.AppriseLocale(language=\"fr\")\n\n    with al.lang_at(\"en\") as _:\n        # functions still behave as normal\n        assert callable(_)\n\n    with al.lang_at(\"es\") as _:\n        # functions still behave as normal\n        assert callable(_)\n\n    with al.lang_at(\"fr\") as _:\n        # functions still behave as normal\n        assert callable(_)\n\n    # Test our initialization when our fallback is a language we do\n    # not have. This is only done to test edge cases when for whatever\n    # reason the person who set up apprise does not have the languages\n    # installed.\n    fallback = locale.AppriseLocale._default_language\n    mock_getlocale.return_value = None\n\n    with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", \"LANG\"):\n        # Our default language\n        locale.AppriseLocale._default_language = \"zz\"\n\n        # We will detect the zz since there were no environment variables to\n        # help us otherwise\n        assert locale.AppriseLocale.detect_language() is None\n        al = locale.AppriseLocale()\n\n        # No Language could be set becuause no locale directory exists for this\n        assert al.lang is None\n\n        with al.lang_at(None) as _:\n            # functions still behave as normal\n            assert callable(_)\n\n        with al.lang_at(\"en\") as _:\n            # functions still behave as normal\n            assert callable(_)\n\n        with al.lang_at(\"es\") as _:\n            # functions still behave as normal\n            assert callable(_)\n\n        with al.lang_at(\"fr\") as _:\n            # functions still behave as normal\n            assert callable(_)\n\n        # We can still perform simple lookups; they access a dummy wrapper:\n        assert al.gettext(\"test\") == \"test\"\n\n    with environ(\"LANGUAGE\", \"LC_CTYPE\", LC_ALL=\"C.UTF-8\", LANG=\"en_CA\"):\n        # the UTF-8 entry is skipped over\n        locale.AppriseLocale._default_language = \"fr\"\n\n        # We will detect the english language (found in the LANG= environment\n        # variable which over-rides the _default\n        assert locale.AppriseLocale.detect_language() == \"en\"\n        al = locale.AppriseLocale()\n        assert al.lang == \"en\"\n        assert al.gettext(\"test\") == \"test\"\n\n        # Test case with set_default set to False (so we're still set to 'fr')\n        assert al.add(\"zy\", set_default=False) is False\n        assert al.gettext(\"test\") == \"test\"\n\n        al.add(\"ab\", set_default=True)\n        assert al.gettext(\"test\") == \"test\"\n\n        assert al.add(\"zy\", set_default=False) is False\n    locale.AppriseLocale._default_language = fallback\n\n\n@pytest.mark.skipif(\"gettext\" not in sys.modules, reason=\"Requires gettext\")\ndef test_apprise_trans_add():\n    \"\"\"\n    API: Apprise() Gettext add\n\n    \"\"\"\n\n    # This throws internally but we handle it gracefully\n    al = locale.AppriseLocale()\n    with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", \"LANG\"):\n        # English is the default/fallback type\n        assert al.add(\"en\") is True\n\n    al = locale.AppriseLocale()\n    with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", LANG=\"C.UTF-8\"):\n        # Test English Environment\n        assert al.add(\"en\") is True\n\n    al = locale.AppriseLocale()\n    with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", LANG=\"en_CA.UTF-8\"):\n        # Test English Environment\n        assert al.add(\"en\") is True\n\n        # Double add (copy of above) to access logic that prevents adding it\n        # again\n        assert al.add(\"en\") is True\n\n    # Invalid Language\n    assert al.add(\"bad\") is False\n\n\n@pytest.mark.skipif(\n    not hasattr(ctypes, \"windll\"), reason=\"Unique Windows test cases\"\n)\n@pytest.mark.skipif(\"gettext\" not in sys.modules, reason=\"Requires gettext\")\n@mock.patch(\"locale.getlocale\")\ndef test_apprise_trans_windows_users_win(mock_getlocale):\n    \"\"\"\n    API: Apprise() Windows Locale Testing (Win version)\n\n    \"\"\"\n\n    # Set- our gettext.locale() return value\n    mock_getlocale.return_value = (\"fr_CA\", \"UTF-8\")\n\n    with mock.patch(\n        \"ctypes.windll.kernel32.GetUserDefaultUILanguage\"\n    ) as ui_lang:\n\n        # 4105 = en_CA\n        ui_lang.return_value = 4105\n\n        with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", \"LANG\"):\n            # Our default language\n            locale.AppriseLocale._default_language = \"zz\"\n\n            # We will pick up the windll module and detect english\n            assert locale.AppriseLocale.detect_language() == \"en\"\n\n        # The below accesses the windows fallback code\n        with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", LANG=\"es_AR\"):\n            # Environment Variable Trumps\n            assert locale.AppriseLocale.detect_language() == \"es\"\n\n        # No environment variable, then the Windows environment is used\n        with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", \"LANG\"):\n            # Windows Environment\n            assert locale.AppriseLocale.detect_language() == \"en\"\n\n        assert (\n            locale.AppriseLocale.detect_language(detect_fallback=False) is None\n        )\n\n        # 0 = IndexError\n        ui_lang.return_value = 0\n        with environ(\"LANGUAGE\", \"LANG\", \"LC_ALL\", \"LC_CTYPE\"):\n            # We fall back to posix locale\n            assert locale.AppriseLocale.detect_language() == \"fr\"\n\n\n@pytest.mark.skipif(hasattr(ctypes, \"windll\"), reason=\"Unique Nux test cases\")\n@pytest.mark.skipif(\"gettext\" not in sys.modules, reason=\"Requires gettext\")\n@mock.patch(\"locale.getlocale\")\ndef test_apprise_trans_windows_users_nux(mock_getlocale):\n    \"\"\"\n    API: Apprise() Windows Locale Testing (Nux version)\n\n    \"\"\"\n\n    # Set- our gettext.locale() return value\n    mock_getlocale.return_value = (\"fr_CA\", \"UTF-8\")\n\n    # Emulate a windows environment\n    windll = mock.Mock()\n    ctypes.windll = windll\n\n    # 4105 = en_CA\n    windll.kernel32.GetUserDefaultUILanguage.return_value = 4105\n\n    # Store default value to not break other tests\n    default_language = locale.AppriseLocale._default_language\n\n    with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", \"LANG\"):\n        # Our default language\n        locale.AppriseLocale._default_language = \"zz\"\n\n        # We will pick up the windll module and detect english\n        assert locale.AppriseLocale.detect_language() == \"en\"\n\n    # The below accesses the windows fallback code\n    with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", LANG=\"es_AR\"):\n        # Environment Variable Trumps\n        assert locale.AppriseLocale.detect_language() == \"es\"\n\n    # No environment variable, then the Windows environment is used\n    with environ(\"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\", \"LANG\"):\n        # Windows Environment\n        assert locale.AppriseLocale.detect_language() == \"en\"\n\n    assert locale.AppriseLocale.detect_language(detect_fallback=False) is None\n\n    # 0 = IndexError\n    windll.kernel32.GetUserDefaultUILanguage.return_value = 0\n    with environ(\"LANGUAGE\", \"LANG\", \"LC_ALL\", \"LC_CTYPE\"):\n        # We fall back to posix locale\n        assert locale.AppriseLocale.detect_language() == \"fr\"\n\n    del ctypes.windll\n\n    # Restore default value\n    locale.AppriseLocale._default_language = default_language\n\n\n@pytest.mark.skipif(sys.platform == \"win32\", reason=\"Unique Nux test cases\")\n@mock.patch(\"locale.getlocale\")\ndef test_detect_language_using_env(mock_getlocale):\n    \"\"\"Test the reading of information from an environment variable.\"\"\"\n\n    # Set- our gettext.locale() return value\n    mock_getlocale.return_value = (\"en_CA\", \"UTF-8\")\n\n    # The below accesses the windows fallback code and fail\n    # then it will resort to the environment variables.\n    with environ(\"LANG\", \"LANGUAGE\", \"LC_ALL\", \"LC_CTYPE\"):\n        # Language can now be detected in this case\n        assert isinstance(locale.AppriseLocale.detect_language(), str)\n\n    # Detect French language.\n    with environ(\"LANGUAGE\", \"LC_ALL\", LC_CTYPE=\"garbage\", LANG=\"fr_CA\"):\n        assert locale.AppriseLocale.detect_language() == \"fr\"\n\n    # The following unsets all environment variables and sets LC_CTYPE\n    # This was causing Python 2.7 to internally parse UTF-8 as an invalid\n    # locale and throw an uncaught ValueError; Python v2 support has been\n    # dropped, but just to ensure this issue does not come back, we keep\n    # this test:\n    with environ(*list(os.environ.keys()), LC_CTYPE=\"UTF-8\"):\n        assert isinstance(locale.AppriseLocale.detect_language(), str)\n\n    # Test with absolutely no environment variables what-so-ever\n    with environ(*list(os.environ.keys())):\n        assert isinstance(locale.AppriseLocale.detect_language(), str)\n\n    # Handle case where getlocale() can't be detected\n    mock_getlocale.return_value = None\n    with environ(\"LC_ALL\", \"LC_CTYPE\", \"LANG\", \"LANGUAGE\"):\n        assert locale.AppriseLocale.detect_language() is None\n\n    mock_getlocale.return_value = (None, None)\n    with environ(\"LC_ALL\", \"LC_CTYPE\", \"LANG\", \"LANGUAGE\"):\n        assert locale.AppriseLocale.detect_language() is None\n\n    # if detect_language and windows env fail us, then we don't\n    # set up a default language on first load\n    locale.AppriseLocale()\n\n\n@pytest.mark.skipif(\"gettext\" not in sys.modules, reason=\"Requires gettext\")\ndef test_apprise_trans_gettext_missing(tmpdir):\n    \"\"\"Verify we can still operate without the gettext library.\"\"\"\n\n    # remove gettext from our system enviroment\n    del sys.modules[\"gettext\"]\n\n    # Make our new path to a fake gettext (used to over-ride real one)\n    # have it fail right out of the gate\n    gettext_dir = tmpdir.mkdir(\"gettext\")\n    gettext_dir.join(\"__init__.py\").write(\"\")\n    gettext_dir.join(\"gettext.py\").write(\"\"\"raise ImportError()\"\"\")\n\n    # Update our path to point path to head\n    sys.path.insert(0, str(gettext_dir))\n\n    # reload our module (forcing the import error when it tries to load gettext\n    reload(sys.modules[\"apprise.locale\"])\n    from apprise import locale\n\n    assert locale.GETTEXT_LOADED is False\n\n    # Now roll our changes back\n    sys.path.pop(0)\n\n    # Reload again (reverting back)\n    reload(sys.modules[\"apprise.locale\"])\n    from apprise import locale\n\n    assert locale.GETTEXT_LOADED is True\n\n\n@mock.patch(\"gettext.translation\")\ndef test_apprise_locale_add_existing_language(mock_translation):\n    \"\"\"Re-add an existing language to hit fallback mapping path.\"\"\"\n    # Return a fake gettext object with a valid gettext() method\n    dummy = mock.Mock()\n    dummy.gettext = lambda x: f\"tr:{x}\"\n    mock_translation.return_value = dummy\n\n    al = locale.AppriseLocale()\n    assert al.add(\"en\", set_default=True) is True\n    assert al.add(\"en\", set_default=False) is True\n\n\n@mock.patch(\"gettext.translation\")\n@mock.patch(\"locale.getlocale\")\ndef test_apprise_trans_successful_translation(\n    mock_getlocale, mock_translation\n):\n    \"\"\"Trigger logger.trace by mocking successful gettext.translation()\"\"\"\n    mock_getlocale.return_value = (\"en_CA\", \"UTF-8\")\n\n    dummy_translation = mock.Mock()\n    dummy_translation.gettext = lambda x: f\"tr:{x}\"\n    mock_translation.return_value = dummy_translation\n\n    al = locale.AppriseLocale()\n    assert al.add(\"en\") is True\n    assert al.gettext(\"test\") == \"tr:test\"\n"
  },
  {
    "path": "tests/test_apprise_utils.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import tzinfo\nfrom inspect import cleandoc\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport re\nimport sys\nfrom unittest import mock\nfrom urllib.parse import unquote\n\nfrom apprise import NotificationManager, utils\n\nlogging.disable(logging.CRITICAL)\n\n# Ensure we don't create .pyc files for these tests\nsys.dont_write_bytecode = True\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\ndef test_parse_qsd():\n    \"utils: parse_qsd() testing\"\n\n    result = utils.parse.parse_qsd(\"a=1&b=&c&d=abcd\")\n    assert isinstance(result, dict)\n    assert len(result) == 4\n    assert \"qsd\" in result\n    assert \"qsd+\" in result\n    assert \"qsd-\" in result\n    assert \"qsd:\" in result\n\n    assert len(result[\"qsd\"]) == 4\n    assert \"a\" in result[\"qsd\"]\n    assert \"b\" in result[\"qsd\"]\n    assert \"c\" in result[\"qsd\"]\n    assert \"d\" in result[\"qsd\"]\n\n    assert len(result[\"qsd-\"]) == 0\n    assert len(result[\"qsd+\"]) == 0\n    assert len(result[\"qsd:\"]) == 0\n\n\ndef test_parse_url_general():\n    \"utils: parse_url() testing\"\n\n    result = utils.parse.parse_url(\"http://hostname\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # GitHub Ticket 1234 - Unparseable Hostname\n    result = utils.parse.parse_url(\"http://5t4m59hl-34343.euw.devtunnels.ms\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"5t4m59hl-34343.euw.devtunnels.ms\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://5t4m59hl-34343.euw.devtunnels.ms\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"http://hostname/\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # colon after hostname without port number is no good\n    assert utils.parse.parse_url(\"http://hostname:\") is None\n\n    # An invalid port\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid\", verify_host=False, strict_port=True\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == \"invalid\"\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:invalid\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # However if we don't verify the host, it is okay\n    result = utils.parse.parse_url(\"http://hostname:\", verify_host=False)\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname:\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # A port of Zero is not valid with strict port checking\n    assert utils.parse.parse_url(\"http://hostname:0\", strict_port=True) is None\n\n    # Without strict port checking however, it is okay\n    result = utils.parse.parse_url(\"http://hostname:0\", strict_port=False)\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 0\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:0\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # A negative port is not valid\n    assert (\n        utils.parse.parse_url(\"http://hostname:-92\", strict_port=True) is None\n    )\n    result = utils.parse.parse_url(\n        \"http://hostname:-92\", verify_host=False, strict_port=True\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == -92\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:-92\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # A port that is too large is not valid\n    assert (\n        utils.parse.parse_url(\"http://hostname:65536\", strict_port=True)\n        is None\n    )\n\n    # This is an accetable port (the maximum)\n    result = utils.parse.parse_url(\"http://hostname:65535\", strict_port=True)\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 65535\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:65535\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # This is an accetable port (the maximum)\n    result = utils.parse.parse_url(\"http://hostname:1\", strict_port=True)\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 1\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:1\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # A port that was identfied as a string is invalid\n    assert (\n        utils.parse.parse_url(\"http://hostname:invalid\", strict_port=True)\n        is None\n    )\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid\", verify_host=False, strict_port=True\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == \"invalid\"\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:invalid\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid\", verify_host=False, strict_port=False\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname:invalid\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:invalid\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid?key=value&-minuskey=mvalue\",\n        verify_host=False,\n        strict_port=False,\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname:invalid\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:invalid\"\n    assert unquote(result[\"qsd\"][\"-minuskey\"]) == \"mvalue\"\n    assert unquote(result[\"qsd\"][\"key\"]) == \"value\"\n    assert unquote(result[\"qsd-\"][\"minuskey\"]) == \"mvalue\"\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # Handling of floats\n    assert (\n        utils.parse.parse_url(\"http://hostname:4.2\", strict_port=True) is None\n    )\n    result = utils.parse.parse_url(\n        \"http://hostname:4.2\", verify_host=False, strict_port=True\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == \"4.2\"\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:4.2\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # A Port of zero is not acceptable for a regular hostname\n    assert utils.parse.parse_url(\"http://hostname:0\", strict_port=True) is None\n\n    # No host verification (zero is an acceptable port when this is the case\n    result = utils.parse.parse_url(\"http://hostname:0\", verify_host=False)\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 0\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:0\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\n        \"http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080\",\n        verify_host=False,\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"[2001:db8:002a:3256:adfe:05c0:0003:0006]\"\n    assert result[\"port\"] == 8080\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert (\n        result[\"url\"] == \"http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080\"\n    )\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\n        \"http://hostname:0\", verify_host=False, strict_port=True\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 0\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:0\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"http://hostname/?-KeY=Value\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"-key\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"-key\"]) == \"Value\"\n    assert \"KeY\" in result[\"qsd-\"]\n    assert unquote(result[\"qsd-\"][\"KeY\"]) == \"Value\"\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"http://hostname/?+KeY=Value\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"+key\" in result[\"qsd\"]\n    assert \"KeY\" in result[\"qsd+\"]\n    assert result[\"qsd+\"][\"KeY\"] == \"Value\"\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"http://hostname/?:kEy=vALUE\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \":key\" in result[\"qsd\"]\n    assert \"kEy\" in result[\"qsd:\"]\n    assert result[\"qsd:\"][\"kEy\"] == \"vALUE\"\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd-\"] == {}\n\n    result = utils.parse.parse_url(\n        \"http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:colon=y\"\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"+key\" in result[\"qsd\"]\n    assert \"-key\" in result[\"qsd\"]\n    assert \":colon\" in result[\"qsd\"]\n    assert result[\"qsd:\"][\"colon\"] == \"y\"\n    assert \"key\" in result[\"qsd\"]\n    assert \"KeY\" in result[\"qsd+\"]\n    assert result[\"qsd+\"][\"KeY\"] == \"ValueA\"\n    assert \"kEy\" in result[\"qsd-\"]\n    assert result[\"qsd-\"][\"kEy\"] == \"ValueB\"\n    assert result[\"qsd\"][\"key\"] == \"Value +C\"\n    assert result[\"qsd\"][\"+key\"] == result[\"qsd+\"][\"KeY\"]\n    assert result[\"qsd\"][\"-key\"] == result[\"qsd-\"][\"kEy\"]\n\n    result = utils.parse.parse_url(\n        \"http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:colon=y\",\n        plus_to_space=True,\n    )\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"+key\" in result[\"qsd\"]\n    assert \"-key\" in result[\"qsd\"]\n    assert \":colon\" in result[\"qsd\"]\n    assert result[\"qsd:\"][\"colon\"] == \"y\"\n    assert \"key\" in result[\"qsd\"]\n    assert \"KeY\" in result[\"qsd+\"]\n    assert result[\"qsd+\"][\"KeY\"] == \"ValueA\"\n    assert \"kEy\" in result[\"qsd-\"]\n    assert result[\"qsd-\"][\"kEy\"] == \"ValueB\"\n    assert result[\"qsd\"][\"key\"] == \"Value  C\"\n    assert result[\"qsd\"][\"+key\"] == result[\"qsd+\"][\"KeY\"]\n    assert result[\"qsd\"][\"-key\"] == result[\"qsd-\"][\"kEy\"]\n\n    result = utils.parse.parse_url(\"http://hostname////\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"http://hostname:40////\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 40\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname:40/\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"HTTP://HoStNaMe:40/test.php\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"HoStNaMe\"\n    assert result[\"port\"] == 40\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/test.php\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"test.php\"\n    assert result[\"url\"] == \"http://HoStNaMe:40/test.php\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"HTTPS://user@hostname/test.py\")\n    assert result[\"schema\"] == \"https\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] == \"user\"\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/test.py\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"test.py\"\n    assert result[\"url\"] == \"https://user@hostname/test.py\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"  HTTPS://///user@@@hostname///test.py  \")\n    assert result[\"schema\"] == \"https\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] == \"user\"\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/test.py\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"test.py\"\n    assert result[\"url\"] == \"https://user@hostname/test.py\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\n        \"HTTPS://user:password@otherHost/full///path/name/\",\n    )\n    assert result[\"schema\"] == \"https\"\n    assert result[\"host\"] == \"otherHost\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] == \"user\"\n    assert result[\"password\"] == \"password\"\n    assert result[\"fullpath\"] == \"/full/path/name/\"\n    assert result[\"path\"] == \"/full/path/name/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"https://user:password@otherHost/full/path/name/\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\n        \"HTTPS://hostname/a/path/ending/with/slash/?key=value\",\n    )\n    assert result[\"schema\"] == \"https\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/a/path/ending/with/slash/\"\n    assert result[\"path\"] == \"/a/path/ending/with/slash/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"https://hostname/a/path/ending/with/slash/\"\n    assert result[\"qsd\"] == {\"key\": \"value\"}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # Handle garbage\n    assert utils.parse.parse_url(None) is None\n\n    result = utils.parse.parse_url(\n        \"mailto://user:password@otherHost/lead2gold@gmail.com\"\n        + \"?from=test@test.com&name=Chris%20Caron&format=text\"\n    )\n    assert result[\"schema\"] == \"mailto\"\n    assert result[\"host\"] == \"otherHost\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] == \"user\"\n    assert result[\"password\"] == \"password\"\n    assert unquote(result[\"fullpath\"]) == \"/lead2gold@gmail.com\"\n    assert result[\"path\"] == \"/\"\n    assert unquote(result[\"query\"]) == \"lead2gold@gmail.com\"\n    assert (\n        unquote(result[\"url\"])\n        == \"mailto://user:password@otherHost/lead2gold@gmail.com\"\n    )\n    assert len(result[\"qsd\"]) == 3\n    assert \"name\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"name\"]) == \"Chris Caron\"\n    assert \"from\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"from\"]) == \"test@test.com\"\n    assert \"format\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"format\"]) == \"text\"\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # Test Passwords with question marks ?; not supported\n    result = utils.parse.parse_url(\"http://user:pass.with.?question@host\")\n    assert result is None\n\n    # just hostnames\n    result = utils.parse.parse_url(\"nuxref.com\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"nuxref.com\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://nuxref.com\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # just host and path\n    result = utils.parse.parse_url(\"invalid/host\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"invalid\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/host\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"host\"\n    assert result[\"url\"] == \"http://invalid/host\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # just all out invalid\n    assert utils.parse.parse_url(\"?\") is None\n    assert utils.parse.parse_url(\"/\") is None\n\n    # Test some illegal strings\n    result = utils.parse.parse_url(object, verify_host=False)\n    assert result is None\n    result = utils.parse.parse_url(None, verify_host=False)\n    assert result is None\n\n    # Just a schema; invalid host\n    result = utils.parse.parse_url(\"test://\")\n    assert result is None\n\n    # Do it again without host validation\n    result = utils.parse.parse_url(\"test://\", verify_host=False)\n    assert result[\"schema\"] == \"test\"\n    # It's worth noting that the hostname is an empty string and is NEVER set\n    # to None if it wasn't specified.\n    assert result[\"host\"] == \"\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"test://\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"testhostname\")\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"testhostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    # The default_schema kicks in here\n    assert result[\"url\"] == \"http://testhostname\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\"example.com\", default_schema=\"unknown\")\n    assert result[\"schema\"] == \"unknown\"\n    assert result[\"host\"] == \"example.com\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    # The default_schema kicks in here\n    assert result[\"url\"] == \"unknown://example.com\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # An empty string without a hostame is still valid if verify_host is set\n    result = utils.parse.parse_url(\"\", verify_host=False)\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] is None\n    assert result[\"path\"] is None\n    assert result[\"query\"] is None\n    # The default_schema kicks in here\n    assert result[\"url\"] == \"http://\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # A messed up URL\n    result = utils.parse.parse_url(\"test://:@/\", verify_host=False)\n    assert result[\"schema\"] == \"test\"\n    assert result[\"host\"] == \"\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] == \"\"\n    assert result[\"password\"] == \"\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"test://:@/\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    result = utils.parse.parse_url(\n        \"crazy://:@//_/@^&/jack.json\", verify_host=False\n    )\n    assert result[\"schema\"] == \"crazy\"\n    assert result[\"host\"] == \"\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] == \"\"\n    assert result[\"password\"] == \"\"\n    assert unquote(result[\"fullpath\"]) == \"/_/@^&/jack.json\"\n    assert unquote(result[\"path\"]) == \"/_/@^&/\"\n    assert result[\"query\"] == \"jack.json\"\n    assert unquote(result[\"url\"]) == \"crazy://:@/_/@^&/jack.json\"\n    assert result[\"qsd\"] == {}\n    assert result[\"qsd-\"] == {}\n    assert result[\"qsd+\"] == {}\n    assert result[\"qsd:\"] == {}\n\n    # Sanitizing\n    result = utils.parse.parse_url(\n        \"hTTp://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:cOlON=YeS\",\n        sanitize=False,\n    )\n\n    assert len(result[\"qsd-\"]) == 1\n    assert len(result[\"qsd+\"]) == 1\n    assert len(result[\"qsd\"]) == 4\n    assert len(result[\"qsd:\"]) == 1\n\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] is None\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"+KeY\" in result[\"qsd\"]\n    assert \"-kEy\" in result[\"qsd\"]\n    assert \":cOlON\" in result[\"qsd\"]\n    assert result[\"qsd:\"][\"cOlON\"] == \"YeS\"\n    assert \"key\" not in result[\"qsd\"]\n    assert \"KeY\" in result[\"qsd+\"]\n    assert result[\"qsd+\"][\"KeY\"] == \"ValueA\"\n    assert \"kEy\" in result[\"qsd-\"]\n    assert result[\"qsd-\"][\"kEy\"] == \"ValueB\"\n    assert result[\"qsd\"][\"KEY\"] == \"Value +C\"\n    assert result[\"qsd\"][\"+KeY\"] == result[\"qsd+\"][\"KeY\"]\n    assert result[\"qsd\"][\"-kEy\"] == result[\"qsd-\"][\"kEy\"]\n\n    # Testing Defect 1264 - whitespaces in url\n    result = utils.parse.parse_url(\n        \"posts://example.com/my endpoint?-token=ab cdefg\"\n    )\n\n    assert len(result[\"qsd-\"]) == 1\n    assert len(result[\"qsd+\"]) == 0\n    assert len(result[\"qsd\"]) == 1\n    assert len(result[\"qsd:\"]) == 0\n\n    assert result[\"schema\"] == \"posts\"\n    assert result[\"host\"] == \"example.com\"\n    assert result[\"port\"] is None\n    assert result[\"user\"] is None\n    assert result[\"password\"] is None\n    assert result[\"fullpath\"] == \"/my%20endpoint\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"my%20endpoint\"\n    assert result[\"url\"] == \"posts://example.com/my%20endpoint\"\n    assert \"-token\" in result[\"qsd\"]\n    assert result[\"qsd-\"][\"token\"] == \"ab cdefg\"\n\n\ndef test_parse_url_simple():\n    \"utils: parse_url() testing\"\n\n    result = utils.parse.parse_url(\"http://hostname\", simple=True)\n    assert len(result) == 3\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"url\"] == \"http://hostname\"\n\n    result = utils.parse.parse_url(\"http://hostname/\", simple=True)\n    assert len(result) == 5\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"http://hostname/\"\n\n    # colon after hostname without port number is no good\n    assert utils.parse.parse_url(\"http://hostname:\", simple=True) is None\n\n    # An invalid port\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid\",\n        verify_host=False,\n        strict_port=True,\n        simple=True,\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == \"invalid\"\n    assert result[\"url\"] == \"http://hostname:invalid\"\n\n    # However if we don't verify the host, it is okay\n    result = utils.parse.parse_url(\n        \"http://hostname:\", verify_host=False, simple=True\n    )\n    assert len(result) == 3\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname:\"\n    assert result[\"url\"] == \"http://hostname:\"\n\n    # A port of Zero is not valid with strict port checking\n    assert (\n        utils.parse.parse_url(\n            \"http://hostname:0\", strict_port=True, simple=True\n        )\n        is None\n    )\n\n    # Without strict port checking however, it is okay\n    result = utils.parse.parse_url(\n        \"http://hostname:0\", strict_port=False, simple=True\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"port\"] == 0\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"url\"] == \"http://hostname:0\"\n\n    # A negative port is not valid\n    assert (\n        utils.parse.parse_url(\n            \"http://hostname:-92\", strict_port=True, simple=True\n        )\n        is None\n    )\n    result = utils.parse.parse_url(\n        \"http://hostname:-92\", verify_host=False, strict_port=True, simple=True\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == -92\n    assert result[\"url\"] == \"http://hostname:-92\"\n\n    # A port that is too large is not valid\n    assert (\n        utils.parse.parse_url(\n            \"http://hostname:65536\", strict_port=True, simple=True\n        )\n        is None\n    )\n\n    # This is an accetable port (the maximum)\n    result = utils.parse.parse_url(\n        \"http://hostname:65535\", strict_port=True, simple=True\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 65535\n    assert result[\"url\"] == \"http://hostname:65535\"\n\n    # This is an accetable port (the maximum)\n    result = utils.parse.parse_url(\n        \"http://hostname:1\", strict_port=True, simple=True\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 1\n    assert result[\"url\"] == \"http://hostname:1\"\n\n    # A port that was identfied as a string is invalid\n    assert (\n        utils.parse.parse_url(\n            \"http://hostname:invalid\", strict_port=True, simple=True\n        )\n        is None\n    )\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid\",\n        verify_host=False,\n        strict_port=True,\n        simple=True,\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == \"invalid\"\n    assert result[\"url\"] == \"http://hostname:invalid\"\n\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid\",\n        verify_host=False,\n        strict_port=False,\n        simple=True,\n    )\n    assert len(result) == 3\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname:invalid\"\n    assert result[\"url\"] == \"http://hostname:invalid\"\n\n    result = utils.parse.parse_url(\n        \"http://hostname:invalid?key=value&-minuskey=mvalue\",\n        verify_host=False,\n        strict_port=False,\n        simple=True,\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname:invalid\"\n    assert result[\"url\"] == \"http://hostname:invalid\"\n    assert isinstance(result[\"qsd\"], dict)\n    assert len(result[\"qsd\"]) == 2\n    assert unquote(result[\"qsd\"][\"-minuskey\"]) == \"mvalue\"\n    assert unquote(result[\"qsd\"][\"key\"]) == \"value\"\n\n    # Handling of floats\n    assert (\n        utils.parse.parse_url(\n            \"http://hostname:4.2\", strict_port=True, simple=True\n        )\n        is None\n    )\n    result = utils.parse.parse_url(\n        \"http://hostname:4.2\", verify_host=False, strict_port=True, simple=True\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == \"4.2\"\n    assert result[\"url\"] == \"http://hostname:4.2\"\n\n    # A Port of zero is not acceptable for a regular hostname\n    assert (\n        utils.parse.parse_url(\n            \"http://hostname:0\", strict_port=True, simple=True\n        )\n        is None\n    )\n\n    # No host verification (zero is an acceptable port when this is the case\n    result = utils.parse.parse_url(\n        \"http://hostname:0\", verify_host=False, simple=True\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 0\n    assert result[\"url\"] == \"http://hostname:0\"\n\n    result = utils.parse.parse_url(\n        \"http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080\",\n        verify_host=False,\n        simple=True,\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"[2001:db8:002a:3256:adfe:05c0:0003:0006]\"\n    assert result[\"port\"] == 8080\n    assert (\n        result[\"url\"] == \"http://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8080\"\n    )\n\n    result = utils.parse.parse_url(\n        \"http://hostname:0\", verify_host=False, strict_port=True, simple=True\n    )\n    assert len(result) == 4\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 0\n    assert result[\"url\"] == \"http://hostname:0\"\n\n    result = utils.parse.parse_url(\"http://hostname/?-KeY=Value\", simple=True)\n    assert len(result) == 6\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"-key\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"-key\"]) == \"Value\"\n\n    result = utils.parse.parse_url(\"http://hostname/?+KeY=Value\", simple=True)\n    assert len(result) == 6\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"+key\" in result[\"qsd\"]\n    assert result[\"qsd\"][\"+key\"] == \"Value\"\n\n    result = utils.parse.parse_url(\"http://hostname/?:kEy=vALUE\", simple=True)\n    assert len(result) == 6\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \":key\" in result[\"qsd\"]\n    assert result[\"qsd\"][\":key\"] == \"vALUE\"\n\n    result = utils.parse.parse_url(\n        \"http://hostname/?+KeY=ValueA&-kEy=ValueB&KEY=Value%20+C&:colon=y\",\n        simple=True,\n    )\n    assert len(result) == 6\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"http://hostname/\"\n    assert \"+key\" in result[\"qsd\"]\n    assert \"-key\" in result[\"qsd\"]\n    assert \":colon\" in result[\"qsd\"]\n    assert result[\"qsd\"][\":colon\"] == \"y\"\n    assert result[\"qsd\"][\"key\"] == \"Value +C\"\n    assert result[\"qsd\"][\"+key\"] == \"ValueA\"\n    assert result[\"qsd\"][\"-key\"] == \"ValueB\"\n\n    result = utils.parse.parse_url(\"http://hostname////\", simple=True)\n    assert len(result) == 5\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"http://hostname/\"\n\n    result = utils.parse.parse_url(\"http://hostname:40////\", simple=True)\n    assert len(result) == 6\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"port\"] == 40\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"http://hostname:40/\"\n\n    result = utils.parse.parse_url(\"HTTP://HoStNaMe:40/test.php\", simple=True)\n    assert len(result) == 7\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"HoStNaMe\"\n    assert result[\"port\"] == 40\n    assert result[\"fullpath\"] == \"/test.php\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"test.php\"\n    assert result[\"url\"] == \"http://HoStNaMe:40/test.php\"\n\n    result = utils.parse.parse_url(\n        \"HTTPS://user@hostname/test.py\", simple=True\n    )\n    assert len(result) == 7\n    assert result[\"schema\"] == \"https\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"user\"] == \"user\"\n    assert result[\"fullpath\"] == \"/test.py\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"test.py\"\n    assert result[\"url\"] == \"https://user@hostname/test.py\"\n\n    result = utils.parse.parse_url(\n        \"  HTTPS://///user@@@hostname///test.py  \", simple=True\n    )\n    assert len(result) == 7\n    assert result[\"schema\"] == \"https\"\n    assert result[\"host\"] == \"hostname\"\n    assert result[\"user\"] == \"user\"\n    assert result[\"fullpath\"] == \"/test.py\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"test.py\"\n    assert result[\"url\"] == \"https://user@hostname/test.py\"\n\n    result = utils.parse.parse_url(\n        \"HTTPS://user:password@otherHost/full///path/name/\",\n        simple=True,\n    )\n    assert len(result) == 7\n    assert result[\"schema\"] == \"https\"\n    assert result[\"host\"] == \"otherHost\"\n    assert result[\"user\"] == \"user\"\n    assert result[\"password\"] == \"password\"\n    assert result[\"fullpath\"] == \"/full/path/name/\"\n    assert result[\"path\"] == \"/full/path/name/\"\n    assert result[\"url\"] == \"https://user:password@otherHost/full/path/name/\"\n\n    # Handle garbage\n    assert utils.parse.parse_url(None) is None\n\n    result = utils.parse.parse_url(\n        \"mailto://user:password@otherHost/lead2gold@gmail.com\"\n        + \"?from=test@test.com&name=Chris%20Caron&format=text\",\n        simple=True,\n    )\n    assert len(result) == 9\n    assert result[\"schema\"] == \"mailto\"\n    assert result[\"host\"] == \"otherHost\"\n    assert result[\"user\"] == \"user\"\n    assert result[\"password\"] == \"password\"\n    assert unquote(result[\"fullpath\"]) == \"/lead2gold@gmail.com\"\n    assert result[\"path\"] == \"/\"\n    assert unquote(result[\"query\"]) == \"lead2gold@gmail.com\"\n    assert (\n        unquote(result[\"url\"])\n        == \"mailto://user:password@otherHost/lead2gold@gmail.com\"\n    )\n    assert len(result[\"qsd\"]) == 3\n    assert \"name\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"name\"]) == \"Chris Caron\"\n    assert \"from\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"from\"]) == \"test@test.com\"\n    assert \"format\" in result[\"qsd\"]\n    assert unquote(result[\"qsd\"][\"format\"]) == \"text\"\n\n    # Test Passwords with question marks ?; not supported\n    result = utils.parse.parse_url(\n        \"http://user:pass.with.?question@host\", simple=True\n    )\n    assert result is None\n\n    # just hostnames\n    result = utils.parse.parse_url(\n        \"nuxref.com\",\n        simple=True,\n    )\n    assert len(result) == 3\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"nuxref.com\"\n    assert result[\"url\"] == \"http://nuxref.com\"\n\n    # just host and path\n    result = utils.parse.parse_url(\"invalid/host\", simple=True)\n    assert len(result) == 6\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"invalid\"\n    assert result[\"fullpath\"] == \"/host\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"query\"] == \"host\"\n    assert result[\"url\"] == \"http://invalid/host\"\n\n    # just all out invalid\n    assert utils.parse.parse_url(\"?\", simple=True) is None\n    assert utils.parse.parse_url(\"/\", simple=True) is None\n\n    # Test some illegal strings\n    result = utils.parse.parse_url(object, verify_host=False, simple=True)\n    assert result is None\n    result = utils.parse.parse_url(None, verify_host=False, simple=True)\n    assert result is None\n\n    # Just a schema; invalid host\n    result = utils.parse.parse_url(\"test://\", simple=True)\n    assert result is None\n\n    # Do it again without host validation\n    result = utils.parse.parse_url(\"test://\", verify_host=False, simple=True)\n    assert len(result) == 2\n    assert result[\"schema\"] == \"test\"\n    assert result[\"url\"] == \"test://\"\n\n    result = utils.parse.parse_url(\"testhostname\", simple=True)\n    assert len(result) == 3\n    assert result[\"schema\"] == \"http\"\n    assert result[\"host\"] == \"testhostname\"\n    # The default_schema kicks in here\n    assert result[\"url\"] == \"http://testhostname\"\n\n    result = utils.parse.parse_url(\n        \"example.com\", default_schema=\"unknown\", simple=True\n    )\n    assert len(result) == 3\n    assert result[\"schema\"] == \"unknown\"\n    assert result[\"host\"] == \"example.com\"\n    # The default_schema kicks in here\n    assert result[\"url\"] == \"unknown://example.com\"\n\n    # An empty string without a hostame is still valid if verify_host is set\n    result = utils.parse.parse_url(\"\", verify_host=False, simple=True)\n    assert len(result) == 2\n    assert result[\"schema\"] == \"http\"\n    # The default_schema kicks in here\n    assert result[\"url\"] == \"http://\"\n\n    # A messed up URL\n    result = utils.parse.parse_url(\n        \"test://:@/\", verify_host=False, simple=True\n    )\n    assert len(result) == 6\n    assert result[\"schema\"] == \"test\"\n    assert result[\"user\"] == \"\"\n    assert result[\"password\"] == \"\"\n    assert result[\"fullpath\"] == \"/\"\n    assert result[\"path\"] == \"/\"\n    assert result[\"url\"] == \"test://:@/\"\n\n    result = utils.parse.parse_url(\n        \"crazy://:@//_/@^&/jack.json\", verify_host=False, simple=True\n    )\n    assert len(result) == 7\n    assert result[\"schema\"] == \"crazy\"\n    assert result[\"user\"] == \"\"\n    assert result[\"password\"] == \"\"\n    assert unquote(result[\"fullpath\"]) == \"/_/@^&/jack.json\"\n    assert unquote(result[\"path\"]) == \"/_/@^&/\"\n    assert result[\"query\"] == \"jack.json\"\n    assert unquote(result[\"url\"]) == \"crazy://:@/_/@^&/jack.json\"\n\n\ndef test_url_assembly():\n    \"\"\" \"utils: url_assembly() testing.\"\"\"\n\n    url = \"schema://user:password@hostname:port/path/?key=value\"\n    assert (\n        utils.parse.url_assembly(\n            **utils.parse.parse_url(url, verify_host=False)\n        )\n        == url\n    )\n\n    # Same URL without trailing slash after path\n    url = \"schema://user:password@hostname:port/path?key=value\"\n    assert (\n        utils.parse.url_assembly(\n            **utils.parse.parse_url(url, verify_host=False)\n        )\n        == url\n    )\n\n    url = \"schema://user@hostname:port/path?key=value\"\n    assert (\n        utils.parse.url_assembly(\n            **utils.parse.parse_url(url, verify_host=False)\n        )\n        == url\n    )\n\n    url = \"schema://hostname:10/a/file.php\"\n    assert (\n        utils.parse.url_assembly(\n            **utils.parse.parse_url(url, verify_host=False)\n        )\n        == url\n    )\n\n    # When spaces and special characters are introduced, the URL\n    # is hard to mimic what was entered. Instead it is normalized\n    url = (\n        \"schema://hostname:10/a space/file.php?\"\n        \"arg=a+space&arg2=a%20space&arg3=a space\"\n    )\n    assert (\n        utils.parse.url_assembly(\n            **utils.parse.parse_url(url, verify_host=False)\n        )\n        == \"schema://hostname:10/a%20space/file.php?\"\n        \"arg=a%2Bspace&arg2=a+space&arg3=a+space\"\n    )\n\n    # encode=True should only be used if you're passing in un-assembled\n    # content... hence the following is likely not what is expected:\n    assert (\n        utils.parse.url_assembly(\n            **utils.parse.parse_url(url, verify_host=False), encode=True\n        )\n        == \"schema://hostname:10/a%2520space/file.php?\"\n        \"arg=a%2Bspace&arg2=a+space&arg3=a+space\"\n    )\n\n    # But the following utilizes the encode=True and produces the\n    # desired effects:\n    content = {\n        \"host\": \"hostname\",\n        # Note that fullpath requires escaping in this case\n        \"fullpath\": \"/a space/file.php\",\n        \"path\": \"/a space/\",\n        \"query\": \"file.php\",\n        \"schema\": \"schema\",\n        # our query arguments also require escaping as well\n        \"qsd\": {\"arg\": \"a+space\", \"arg2\": \"a space\", \"arg3\": \"a space\"},\n    }\n    assert (\n        utils.parse.url_assembly(**content, encode=True)\n        == \"schema://hostname/a%20space/file.php?\"\n        \"arg=a%2Bspace&arg2=a+space&arg3=a+space\"\n    )\n\n\ndef test_parse_bool():\n    \"utils: parse_bool() testing\"\n\n    assert utils.parse.parse_bool(\"Enabled\", None) is True\n    assert utils.parse.parse_bool(\"Disabled\", None) is False\n    assert utils.parse.parse_bool(\"Allow\", None) is True\n    assert utils.parse.parse_bool(\"Deny\", None) is False\n    assert utils.parse.parse_bool(\"Yes\", None) is True\n    assert utils.parse.parse_bool(\"YES\", None) is True\n    assert utils.parse.parse_bool(\"Always\", None) is True\n    assert utils.parse.parse_bool(\"No\", None) is False\n    assert utils.parse.parse_bool(\"NO\", None) is False\n    assert utils.parse.parse_bool(\"NEVER\", None) is False\n    assert utils.parse.parse_bool(\"TrUE\", None) is True\n    assert utils.parse.parse_bool(\"tRUe\", None) is True\n    assert utils.parse.parse_bool(\"FAlse\", None) is False\n    assert utils.parse.parse_bool(\"F\", None) is False\n    assert utils.parse.parse_bool(\"T\", None) is True\n    assert utils.parse.parse_bool(\"0\", None) is False\n    assert utils.parse.parse_bool(\"1\", None) is True\n    assert utils.parse.parse_bool(\"True\", None) is True\n    assert utils.parse.parse_bool(\"Yes\", None) is True\n    assert utils.parse.parse_bool(1, None) is True\n    assert utils.parse.parse_bool(0, None) is False\n    assert utils.parse.parse_bool(True, None) is True\n    assert utils.parse.parse_bool(False, None) is False\n\n    # only the int of 0 will return False since the function\n    # casts this to a boolean\n    assert utils.parse.parse_bool(2, None) is True\n    # An empty list is still false\n    assert utils.parse.parse_bool([], None) is False\n    # But a list that contains something is True\n    assert (\n        utils.parse.parse_bool(\n            [\n                \"value\",\n            ],\n            None,\n        )\n        is True\n    )\n\n    # Use Default (which is False)\n    assert utils.parse.parse_bool(\"OhYeah\") is False\n    # Adjust Default and get a different result\n    assert utils.parse.parse_bool(\"OhYeah\", True) is True\n\n\ndef test_is_uuid():\n    \"\"\"\n    API: is_uuid() function\n    \"\"\"\n    # Invalid Entries\n    assert utils.parse.is_uuid(\"invalid\") is False\n    assert utils.parse.is_uuid(None) is False\n    assert utils.parse.is_uuid(5) is False\n    assert utils.parse.is_uuid(object) is False\n\n    # A slightly invalid uuid4 entry\n    assert utils.parse.is_uuid(\"591ed387-fa65-ac97-9712-b9d2a15e42a9\") is False\n    assert utils.parse.is_uuid(\"591ed387-fa65-Jc97-9712-b9d2a15e42a9\") is False\n\n    # Valid UUID4 Entries\n    assert utils.parse.is_uuid(\"591ed387-fa65-4c97-9712-b9d2a15e42a9\") is True\n    assert utils.parse.is_uuid(\"32b0b447-fe84-4df1-8368-81925e729265\") is True\n\n\ndef test_is_hostname():\n    \"\"\"\n    API: is_hostname() function\n\n    \"\"\"\n    # Valid Hostnames\n    assert utils.parse.is_hostname(\"yahoo.ca\") == \"yahoo.ca\"\n    assert utils.parse.is_hostname(\"yahoo.ca.\") == \"yahoo.ca\"\n    assert (\n        utils.parse.is_hostname(\"valid-dashes-in-host.ca\")\n        == \"valid-dashes-in-host.ca\"\n    )\n    assert (\n        utils.parse.is_hostname(\"valid-underscores_in_host.ca\")\n        == \"valid-underscores_in_host.ca\"\n    )\n\n    # Underscores are supported by default\n    assert (\n        utils.parse.is_hostname(\"valid_dashes_in_host.ca\")\n        == \"valid_dashes_in_host.ca\"\n    )\n    # However they are not if specified otherwise:\n    assert (\n        utils.parse.is_hostname(\"valid_dashes_in_host.ca\", underscore=False)\n        is False\n    )\n\n    # Invalid Hostnames\n    assert (\n        utils.parse.is_hostname(\"-hostname.that.starts.with.a.dash\") is False\n    )\n    assert utils.parse.is_hostname(\"invalid-characters_#^.ca\") is False\n    assert utils.parse.is_hostname(\"    spaces   \") is False\n    assert utils.parse.is_hostname(\"       \") is False\n    assert utils.parse.is_hostname(\"\") is False\n\n    # Valid IPv4 Addresses\n    assert utils.parse.is_hostname(\"127.0.0.1\") == \"127.0.0.1\"\n    assert utils.parse.is_hostname(\"0.0.0.0\") == \"0.0.0.0\"\n    assert utils.parse.is_hostname(\"255.255.255.255\") == \"255.255.255.255\"\n\n    # But not if we're not checking for this:\n    assert utils.parse.is_hostname(\"127.0.0.1\", ipv4=False) is False\n    assert utils.parse.is_hostname(\"0.0.0.0\", ipv4=False) is False\n    assert utils.parse.is_hostname(\"255.255.255.255\", ipv4=False) is False\n\n    # Invalid IPv4 Addresses\n    assert utils.parse.is_hostname(\"1.2.3\") is False\n    assert utils.parse.is_hostname(\"256.256.256.256\") is False\n    assert utils.parse.is_hostname(\"999.0.0.0\") is False\n    assert utils.parse.is_hostname(\"1.2.3.4.5\") is False\n    assert utils.parse.is_hostname(\"    127.0.0.1   \") is False\n    assert utils.parse.is_hostname(\"       \") is False\n    assert utils.parse.is_hostname(\"\") is False\n\n    # Valid IPv6 Addresses (square brakets supported for URL construction)\n    assert (\n        utils.parse.is_hostname(\"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]\")\n        == \"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]\"\n    )\n    assert (\n        utils.parse.is_hostname(\"2001:0db8:85a3:0000:0000:8a2e:0370:7334\")\n        == \"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]\"\n    )\n    assert (\n        utils.parse.is_hostname(\"[2001:db8:002a:3256:adfe:05c0:0003:0006]\")\n        == \"[2001:db8:002a:3256:adfe:05c0:0003:0006]\"\n    )\n\n    # localhost\n    assert utils.parse.is_hostname(\"::1\") == \"[::1]\"\n    assert utils.parse.is_hostname(\"0:0:0:0:0:0:0:1\") == \"[0:0:0:0:0:0:0:1]\"\n\n    # But not if we're not checking for this:\n    assert (\n        utils.parse.is_hostname(\n            \"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]\", ipv6=False\n        )\n        is False\n    )\n    assert (\n        utils.parse.is_hostname(\n            \"2001:0db8:85a3:0000:0000:8a2e:0370:7334\", ipv6=False\n        )\n        is False\n    )\n\n    # Test hostnames with a single character hostname\n    assert (\n        utils.parse.is_hostname(\"cloud.a.example.com\", ipv4=False, ipv6=False)\n        == \"cloud.a.example.com\"\n    )\n\n\ndef test_is_ipaddr():\n    \"\"\"\n    API: is_ipaddr() function\n\n    \"\"\"\n    # Valid IPv4 Addresses\n    assert utils.parse.is_ipaddr(\"127.0.0.1\") == \"127.0.0.1\"\n    assert utils.parse.is_ipaddr(\"0.0.0.0\") == \"0.0.0.0\"\n    assert utils.parse.is_ipaddr(\"255.255.255.255\") == \"255.255.255.255\"\n\n    # Invalid IPv4 Addresses\n    assert utils.parse.is_ipaddr(\"1.2.3\") is False\n    assert utils.parse.is_ipaddr(\"256.256.256.256\") is False\n    assert utils.parse.is_ipaddr(\"999.0.0.0\") is False\n    assert utils.parse.is_ipaddr(\"1.2.3.4.5\") is False\n    assert utils.parse.is_ipaddr(\"    127.0.0.1   \") is False\n    assert utils.parse.is_ipaddr(\"       \") is False\n    assert utils.parse.is_ipaddr(\"\") is False\n\n    # Valid IPv6 Addresses (square brakets supported for URL construction)\n    assert (\n        utils.parse.is_ipaddr(\"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]\")\n        == \"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]\"\n    )\n    assert (\n        utils.parse.is_ipaddr(\"2001:0db8:85a3:0000:0000:8a2e:0370:7334\")\n        == \"[2001:0db8:85a3:0000:0000:8a2e:0370:7334]\"\n    )\n    assert (\n        utils.parse.is_ipaddr(\"[2001:db8:002a:3256:adfe:05c0:0003:0006]\")\n        == \"[2001:db8:002a:3256:adfe:05c0:0003:0006]\"\n    )\n\n    # localhost\n    assert utils.parse.is_ipaddr(\"::1\") == \"[::1]\"\n    assert utils.parse.is_ipaddr(\"0:0:0:0:0:0:0:1\") == \"[0:0:0:0:0:0:0:1]\"\n\n\ndef test_is_email():\n    \"\"\"\n    API: is_email() function\n\n    \"\"\"\n    # Valid Emails\n    results = utils.parse.is_email(\"test@gmail.com\")\n    assert results[\"name\"] == \"\"\n    assert results[\"email\"] == \"test@gmail.com\"\n    assert results[\"full_email\"] == \"test@gmail.com\"\n    assert results[\"domain\"] == \"gmail.com\"\n    assert results[\"user\"] == \"test\"\n    assert results[\"label\"] == \"\"\n\n    results = utils.parse.is_email(\"test@my-valid_host.com\")\n    assert results[\"name\"] == \"\"\n    assert results[\"email\"] == \"test@my-valid_host.com\"\n    assert results[\"full_email\"] == \"test@my-valid_host.com\"\n    assert results[\"domain\"] == \"my-valid_host.com\"\n    assert results[\"user\"] == \"test\"\n    assert results[\"label\"] == \"\"\n\n    results = utils.parse.is_email(\"tag+test@gmail.com\")\n    assert results[\"name\"] == \"\"\n    assert results[\"email\"] == \"test@gmail.com\"\n    assert results[\"full_email\"] == \"tag+test@gmail.com\"\n    assert results[\"domain\"] == \"gmail.com\"\n    assert results[\"user\"] == \"test\"\n    assert results[\"label\"] == \"tag\"\n\n    # Support Full Names as well\n    results = utils.parse.is_email(\"Bill Gates: bgates@microsoft.com\")\n    assert results[\"name\"] == \"Bill Gates\"\n    assert results[\"email\"] == \"bgates@microsoft.com\"\n    assert results[\"full_email\"] == \"bgates@microsoft.com\"\n    assert results[\"domain\"] == \"microsoft.com\"\n    assert results[\"user\"] == \"bgates\"\n    assert results[\"label\"] == \"\"\n\n    results = utils.parse.is_email(\"Bill Gates <bgates@microsoft.com>\")\n    assert results[\"name\"] == \"Bill Gates\"\n    assert results[\"email\"] == \"bgates@microsoft.com\"\n    assert results[\"full_email\"] == \"bgates@microsoft.com\"\n    assert results[\"domain\"] == \"microsoft.com\"\n    assert results[\"user\"] == \"bgates\"\n    assert results[\"label\"] == \"\"\n\n    results = utils.parse.is_email(\"Bill Gates: <bgates@microsoft.com>\")\n    assert results[\"name\"] == \"Bill Gates\"\n    assert results[\"email\"] == \"bgates@microsoft.com\"\n    assert results[\"full_email\"] == \"bgates@microsoft.com\"\n    assert results[\"domain\"] == \"microsoft.com\"\n    assert results[\"user\"] == \"bgates\"\n    assert results[\"label\"] == \"\"\n\n    results = utils.parse.is_email(\"Sundar Pichai <ceo+spichai@gmail.com>\")\n    assert results[\"name\"] == \"Sundar Pichai\"\n    assert results[\"email\"] == \"spichai@gmail.com\"\n    assert results[\"full_email\"] == \"ceo+spichai@gmail.com\"\n    assert results[\"domain\"] == \"gmail.com\"\n    assert results[\"user\"] == \"spichai\"\n    assert results[\"label\"] == \"ceo\"\n\n    # Support Quotes\n    results = utils.parse.is_email('\"Chris Hemsworth\" <ch@test.com>')\n    assert results[\"name\"] == \"Chris Hemsworth\"\n    assert results[\"email\"] == \"ch@test.com\"\n    assert results[\"full_email\"] == \"ch@test.com\"\n    assert results[\"domain\"] == \"test.com\"\n    assert results[\"user\"] == \"ch\"\n    assert results[\"label\"] == \"\"\n\n    # An email without name, but contains delimiters\n    results = utils.parse.is_email(\"      <spichai@gmail.com>\")\n    assert results[\"name\"] == \"\"\n    assert results[\"email\"] == \"spichai@gmail.com\"\n    assert results[\"full_email\"] == \"spichai@gmail.com\"\n    assert results[\"domain\"] == \"gmail.com\"\n    assert results[\"user\"] == \"spichai\"\n    assert results[\"label\"] == \"\"\n\n    # a valid email not properly delimited with a colon or angle bracket\n    # We do a best guess and still parse it correctly\n    results = utils.parse.is_email(\"Name valid@example.com\")\n    assert results[\"name\"] == \"Name\"\n    assert results[\"email\"] == \"valid@example.com\"\n    assert results[\"full_email\"] == \"valid@example.com\"\n    assert results[\"domain\"] == \"example.com\"\n    assert results[\"user\"] == \"valid\"\n    assert results[\"label\"] == \"\"\n\n    # a valid email not properly delimited with a colon or angle bracket\n    # We do a best guess and still parse it correctly\n    results = utils.parse.is_email(\"Руслан Эра russian+russia@example.ru\")\n    assert results[\"name\"] == \"Руслан Эра\"\n    assert results[\"email\"] == \"russia@example.ru\"\n    assert results[\"full_email\"] == \"russian+russia@example.ru\"\n    assert results[\"domain\"] == \"example.ru\"\n    assert results[\"user\"] == \"russia\"\n    assert results[\"label\"] == \"russian\"\n\n    # Invalid Emails\n    assert utils.parse.is_email(\"invalid.com\") is False\n    assert utils.parse.is_email(object()) is False\n    assert utils.parse.is_email(None) is False\n    assert utils.parse.is_email(\"Just A Name\") is False\n    assert utils.parse.is_email(\"Name <bademail>\") is False\n\n    # Extended valid emails\n    #\n    # The first + denotes our label, so this test really validates\n    # that there is a correct split and parsing of our email\n    results = utils.parse.is_email(\"a-z0-9_!#$%&*+/=?%`{|}~^.-@gmail.com\")\n    assert results[\"name\"] == \"\"\n    assert results[\"label\"] == \"a-z0-9_!#$%&*\"\n    assert results[\"email\"] == \"/=?%`{|}~^.-@gmail.com\"\n    assert results[\"full_email\"] == \"a-z0-9_!#$%&*+/=?%`{|}~^.-@gmail.com\"\n    assert results[\"domain\"] == \"gmail.com\"\n    assert results[\"user\"] == \"/=?%`{|}~^.-\"\n\n    # A similar test without '+' (use of a label)\n\n    # The first + denotes our label, so this test really validates\n    # that there is a correct split and parsing of our email\n    results = utils.parse.is_email(\"a-z0-9_!#$%&*/=?%`{|}~^.-@gmail.com\")\n    assert results[\"name\"] == \"\"\n    assert results[\"label\"] == \"\"\n    assert results[\"email\"] == \"a-z0-9_!#$%&*/=?%`{|}~^.-@gmail.com\"\n    assert results[\"full_email\"] == \"a-z0-9_!#$%&*/=?%`{|}~^.-@gmail.com\"\n    assert results[\"domain\"] == \"gmail.com\"\n    assert results[\"user\"] == \"a-z0-9_!#$%&*/=?%`{|}~^.-\"\n\n\ndef test_is_call_sign_no():\n    \"\"\"\n    API: is_call_sign() function\n\n    \"\"\"\n    # Invalid numbers\n    assert utils.parse.is_call_sign(None) is False\n    assert utils.parse.is_call_sign(42) is False\n    assert utils.parse.is_call_sign(object) is False\n    assert utils.parse.is_call_sign(\"\") is False\n    assert utils.parse.is_call_sign(\"1\") is False\n    assert utils.parse.is_call_sign(\"12\") is False\n    assert utils.parse.is_call_sign(\"abc\") is False\n    assert utils.parse.is_call_sign(\"+()\") is False\n    assert utils.parse.is_call_sign(\"+\") is False\n    assert utils.parse.is_call_sign(None) is False\n    assert utils.parse.is_call_sign(42) is False\n\n    # To short or 2 long\n    assert utils.parse.is_call_sign(\"DF1ABCX\") is False\n    assert utils.parse.is_call_sign(\"DF1ABCEFG\") is False\n    assert utils.parse.is_call_sign(\"1ABCX\") is False\n    # 4th character is not an number\n    assert utils.parse.is_call_sign(\"XXXXXX\") is False\n\n    # Some valid checks\n    # 1x2\n    result = utils.parse.is_call_sign(\"A0AF\")\n    assert isinstance(result, dict)\n    assert result[\"callsign\"] == \"A0AF\"\n    assert result[\"ssid\"] == \"\"\n\n    # 2x1\n    result = utils.parse.is_call_sign(\"AA0A\")\n    assert isinstance(result, dict)\n    assert result[\"callsign\"] == \"AA0A\"\n    assert result[\"ssid\"] == \"\"\n\n    # 2x2\n    result = utils.parse.is_call_sign(\"AA0AF\")\n    assert isinstance(result, dict)\n    assert result[\"callsign\"] == \"AA0AF\"\n    assert result[\"ssid\"] == \"\"\n\n    result = utils.parse.is_call_sign(\"AA0AF-23\")\n    assert isinstance(result, dict)\n    assert result[\"callsign\"] == \"AA0AF\"\n    assert result[\"ssid\"] == \"23\"\n\n    # 1x3\n    result = utils.parse.is_call_sign(\"K0ACL\")\n    assert isinstance(result, dict)\n    assert result[\"callsign\"] == \"K0ACL\"\n    assert result[\"ssid\"] == \"\"\n\n    # 2x3\n    result = utils.parse.is_call_sign(\"DF1ABC\")\n    assert isinstance(result, dict)\n    assert result[\"callsign\"] == \"DF1ABC\"\n    assert result[\"ssid\"] == \"\"\n\n    # Get our SSID\n    result = utils.parse.is_call_sign(\"DF1ABC-14\")\n    assert result[\"callsign\"] == \"DF1ABC\"\n    assert result[\"ssid\"] == \"14\"\n\n\ndef test_is_phone_no():\n    \"\"\"\n    API: is_phone_no() function\n\n    \"\"\"\n    # Invalid numbers\n    assert utils.parse.is_phone_no(None) is False\n    assert utils.parse.is_phone_no(42) is False\n    assert utils.parse.is_phone_no(object) is False\n    assert utils.parse.is_phone_no(\"\") is False\n    assert utils.parse.is_phone_no(\"1\") is False\n    assert utils.parse.is_phone_no(\"12\") is False\n    assert utils.parse.is_phone_no(\"abc\") is False\n    assert utils.parse.is_phone_no(\"+()\") is False\n    assert utils.parse.is_phone_no(\"+\") is False\n    assert utils.parse.is_phone_no(None) is False\n    assert utils.parse.is_phone_no(42) is False\n    assert utils.parse.is_phone_no(object, min_len=0) is False\n    assert utils.parse.is_phone_no(\"\", min_len=1) is False\n    assert utils.parse.is_phone_no(\"abc\", min_len=0) is False\n    assert utils.parse.is_phone_no(\"\", min_len=0) is False\n\n    # Ambigious, but will document it here in this test as such\n    results = utils.parse.is_phone_no(\"+((()))--+\", min_len=0)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"\"\n    assert results[\"pretty\"] == \"\"\n    assert results[\"full\"] == \"\"\n\n    # Valid phone numbers\n    assert utils.parse.is_phone_no(\"+(0)\") is False\n    results = utils.parse.is_phone_no(\"+(0)\", min_len=1)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"0\"\n    assert results[\"pretty\"] == \"0\"\n    assert results[\"full\"] == \"0\"\n\n    assert utils.parse.is_phone_no(\"1\") is False\n    results = utils.parse.is_phone_no(\"1\", min_len=1)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"1\"\n    assert results[\"pretty\"] == \"1\"\n    assert results[\"full\"] == \"1\"\n\n    assert utils.parse.is_phone_no(\"12\") is False\n    results = utils.parse.is_phone_no(\"12\", min_len=2)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"12\"\n    assert results[\"pretty\"] == \"12\"\n    assert results[\"full\"] == \"12\"\n\n    assert utils.parse.is_phone_no(\"911\") is False\n    results = utils.parse.is_phone_no(\"911\", min_len=3)\n    assert isinstance(results, dict)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"911\"\n    assert results[\"pretty\"] == \"911\"\n    assert results[\"full\"] == \"911\"\n\n    assert utils.parse.is_phone_no(\"1234\") is False\n    results = utils.parse.is_phone_no(\"1234\", min_len=4)\n    assert isinstance(results, dict)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"1234\"\n    assert results[\"pretty\"] == \"1234\"\n    assert results[\"full\"] == \"1234\"\n\n    assert utils.parse.is_phone_no(\"12345\") is False\n    results = utils.parse.is_phone_no(\"12345\", min_len=5)\n    assert isinstance(results, dict)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"12345\"\n    assert results[\"pretty\"] == \"12345\"\n    assert results[\"full\"] == \"12345\"\n\n    assert utils.parse.is_phone_no(\"123456\") is False\n    results = utils.parse.is_phone_no(\"123456\", min_len=6)\n    assert isinstance(results, dict)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"123456\"\n    assert results[\"pretty\"] == \"123456\"\n    assert results[\"full\"] == \"123456\"\n\n    # at 7 digits, the format hyphenates in the `pretty` section\n    assert utils.parse.is_phone_no(\"1234567\") is False\n    results = utils.parse.is_phone_no(\"1234567\", min_len=7)\n    assert isinstance(results, dict)\n    assert results[\"country\"] == \"\"\n    assert results[\"area\"] == \"\"\n    assert results[\"line\"] == \"1234567\"\n    assert results[\"pretty\"] == \"123-4567\"\n    assert results[\"full\"] == \"1234567\"\n\n    results = utils.parse.is_phone_no(\"1(800) 123-4567\")\n    assert isinstance(results, dict)\n    assert results[\"country\"] == \"1\"\n    assert results[\"area\"] == \"800\"\n    assert results[\"line\"] == \"1234567\"\n    assert results[\"pretty\"] == \"+1 800-123-4567\"\n    assert results[\"full\"] == \"18001234567\"\n\n\ndef test_parse_call_sign():\n    \"\"\"utils: parse_call_sign() testing\"\"\"\n    # A simple single array entry (As str)\n    results = utils.parse.parse_call_sign(\"\")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # just delimeters\n    results = utils.parse.parse_call_sign(\",  ,, , ,,, \")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_call_sign(None)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_call_sign(42)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_call_sign(\n        \"this is not a parseable call sign at all\"\n    )\n    assert isinstance(results, list)\n    assert len(results) == 9\n\n    results = utils.parse.parse_call_sign(\n        \"this is not a parseable call sign at all\", store_unparseable=False\n    )\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # Now test valid call signs\n    results = utils.parse.parse_call_sign(\"0A1DEF\")\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert \"0A1DEF\" in results\n\n    results = utils.parse.parse_call_sign(\"0A1DEF, DF1ABC\")\n    assert isinstance(results, list)\n    assert len(results) == 2\n    assert \"0A1DEF\" in results\n    assert \"DF1ABC\" in results\n\n    results = utils.parse.parse_call_sign(\"AA0A, A0AF-12, GARBAGE\")\n    assert isinstance(results, list)\n    assert len(results) == 2\n    assert \"AA0A\" in results\n    assert \"A0AF-12\" in results\n\n\ndef test_parse_phone_no():\n    \"\"\"utils: parse_phone_no() testing\"\"\"\n    # A simple single array entry (As str)\n    results = utils.parse.parse_phone_no(\"\")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # just delimeters\n    results = utils.parse.parse_phone_no(\",  ,, , ,,, \")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_phone_no(\",\")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_phone_no(None)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_phone_no(42)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_phone_no(\n        \"this is not a parseable phoneno at all\"\n    )\n    assert isinstance(results, list)\n    assert len(results) == 8\n    # Now we do it again with the store_unparsable flag set to False\n    results = utils.parse.parse_phone_no(\n        \"this is not a parseable email at all\", store_unparseable=False\n    )\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_phone_no(\"+\", store_unparseable=False)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_phone_no(\"(\", store_unparseable=False)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # Number is too short\n    results = utils.parse.parse_phone_no(\"0\", store_unparseable=False)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_phone_no(\"12\", store_unparseable=False)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # Now test valid phone numbers\n    results = utils.parse.parse_phone_no(\"+1 (124) 245 2345\")\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert \"+1 (124) 245 2345\" in results\n\n    results = utils.parse.parse_phone_no(\"911\", store_unparseable=False)\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert \"911\" in results\n\n    results = utils.parse.parse_phone_no(\n        \"911, 123-123-1234\", store_unparseable=False\n    )\n    assert isinstance(results, list)\n    assert len(results) == 2\n    assert \"911\" in results\n    assert \"123-123-1234\" in results\n\n    # Space variations\n    results = utils.parse.parse_phone_no(\" 911  , +1 (123) 123-1234\")\n    assert isinstance(results, list)\n    assert len(results) == 2\n    assert \"911\" in results\n    assert \"+1 (123) 123-1234\" in results\n\n    results = utils.parse.parse_phone_no(\" 911  , + 1 ( 123 ) 123-1234\")\n    assert isinstance(results, list)\n    assert len(results) == 2\n    assert \"911\" in results\n    assert \"+ 1 ( 123 ) 123-1234\" in results\n\n\ndef test_parse_emails():\n    \"\"\"utils: parse_emails() testing\"\"\"\n    # A simple single array entry (As str)\n    results = utils.parse.parse_emails(\"\")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # just delimeters\n    results = utils.parse.parse_emails(\",  ,, , ,,, \")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_emails(\",\")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_emails(None)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_emails(42)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_emails(\"this is not a parseable email at all\")\n    assert isinstance(results, list)\n    assert len(results) == 8\n    # Now we do it again with the store_unparsable flag set to False\n    results = utils.parse.parse_emails(\n        \"this is not a parseable email at all\", store_unparseable=False\n    )\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # Now test valid emails\n    results = utils.parse.parse_emails(\"user@example.com\")\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert \"user@example.com\" in results\n\n    results = utils.parse.parse_emails(\"a@\")\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert \"a@\" in results\n\n    results = utils.parse.parse_emails(\"user1@example.com user2@example.com\")\n    assert isinstance(results, list)\n    assert len(results) == 2\n    assert \"user1@example.com\" in results\n    assert \"user2@example.com\" in results\n\n    # Commas and spaces found inside URLs are ignored\n    emails = [\n        \"user1@example.com,\",\n        \"test1@example.com,,, abcd@example.com\",\n        \"Chuck Norris roundhouse@kick.com\",\n        \"David Spade dspade@example.com, Yours Truly yours@truly.com\",\n    ]\n\n    results = utils.parse.parse_emails(\", \".join(emails))\n    assert isinstance(results, list)\n    assert len(results) == 6\n    assert \"user1@example.com\" in results\n    assert \"test1@example.com\" in results\n    assert \"abcd@example.com\" in results\n    assert \"Chuck Norris roundhouse@kick.com\" in results\n    assert \"David Spade dspade@example.com\" in results\n    assert \"Yours Truly yours@truly.com\" in results\n\n    # Test triangle bracket parsing\n    # Commas and spaces found inside URLs are ignored\n    emails = [\n        \"User1 user1@example.com\",\n        \"User 2 user2@example.com\",\n        \"User Three <user3@example.com>\",\n        \"The Forth User: <user4@example.com>\",\n        \"5th User: user4@example.com\",\n    ]\n\n    results = utils.parse.parse_emails(\", \".join(emails))\n    assert isinstance(results, list)\n    assert len(results) == len(emails)\n    for email in emails:\n        assert email in results\n        is_email = utils.parse.is_email(email)\n        assert is_email\n        assert is_email.get(\"name\")\n\n    # pass the entries in as a list\n    results = utils.parse.parse_emails(emails)\n    assert isinstance(results, list)\n    assert len(results) == len(emails)\n    for email in emails:\n        assert email in results\n\n    # Pass in some unparseables\n    results = utils.parse.parse_emails(\"garbage\")\n    assert isinstance(results, list)\n    assert len(results) == 1\n\n    results = utils.parse.parse_emails(\"garbage\", store_unparseable=False)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # Pass in garbage\n    results = utils.parse.parse_emails(object)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_emails(42)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_emails([None, object, 42])\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n\ndef test_parse_urls():\n    \"\"\"utils: parse_urls() testing\"\"\"\n    # A simple single array entry (As str)\n    results = utils.parse.parse_urls(\"\")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # just delimeters\n    results = utils.parse.parse_urls(\",  ,, , ,,, \")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_urls(\",\")\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_urls(None)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_urls(42)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_urls(\"this is not a parseable url at all\")\n    assert isinstance(results, list)\n    # we still end up returning this\n    assert len(results) == 8\n\n    results = utils.parse.parse_urls(\n        \"this is not a parseable url at all\", store_unparseable=False\n    )\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    # Now test valid URLs\n    results = utils.parse.parse_urls(\"windows://\")\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert \"windows://\" in results\n\n    results = utils.parse.parse_urls(\"windows:// gnome://\")\n    assert isinstance(results, list)\n    assert len(results) == 2\n    assert \"windows://\" in results\n    assert \"gnome://\" in results\n\n    # We don't want to parse out URLs that are part of another URL's arguments\n    results = utils.parse.parse_urls(\"discord://host?url=https://localhost\")\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert \"discord://host?url=https://localhost\" in results\n\n    # Commas and spaces found inside URLs are ignored\n    urls = [\n        (\n            \"mailgun://noreply@sandbox.mailgun.org/apikey/\"\n            \"?to=test@example.com,test2@example.com,,\"\n            \" abcd@example.com\"\n        ),\n        (\n            \"mailgun://noreply@sandbox.another.mailgun.org/apikey/\"\n            \"?to=hello@example.com,,hmmm@example.com,, abcd@example.com, ,\"\n        ),\n        \"windows://\",\n    ]\n\n    # Since comma's and whitespace are the delimiters; they won't be\n    # present at the end of the URL; so we just need to write a special\n    # rstrip() as a regular exression to handle whitespace (\\s) and comma\n    # delimiter\n    rstrip_re = re.compile(r\"[\\s,]+$\")\n\n    # Since a comma acts as a delimiter, we run a risk of a problem where the\n    # comma exists as part of the URL and is therefore lost if it was found\n    # at the end of it.\n\n    results = utils.parse.parse_urls(\", \".join(urls))\n    assert isinstance(results, list)\n    assert len(results) == len(urls)\n    for url in urls:\n        assert rstrip_re.sub(\"\", url) in results\n\n    # However if a comma is found at the end of a single url without a new\n    # match to hit, it is saved and not lost\n\n    # The comma at the end of the password will not be lost if we're\n    # dealing with a single entry:\n    url = \"http://hostname?password=,abcd,\"\n    results = utils.parse.parse_urls(url)\n    assert isinstance(results, list)\n    assert len(results) == 1\n    assert url in results\n\n    # however if we have multiple entries, commas and spaces between\n    # URLs will be lost, however the last URL will not lose the comma\n    urls = [\n        \"schema1://hostname?password=,abcd,\",\n        \"schema2://hostname?password=,abcd,\",\n    ]\n    results = utils.parse.parse_urls(\", \".join(urls))\n    assert isinstance(results, list)\n    assert len(results) == len(urls)\n\n    # No match because the comma is gone in the results entry\n    # schema1://hostname?password=,abcd\n    assert urls[0] not in results\n    assert urls[0][:-1] in results\n\n    # However we wouldn't have lost the comma in the second one:\n    # schema2://hostname?password=,abcd,\n    assert urls[1] in results\n\n    # Pass the list in (as a list); results are the same\n    results = utils.parse.parse_urls(urls)\n    assert isinstance(results, list)\n    assert len(results) == len(urls)\n\n    # Pass in garbage\n    results = utils.parse.parse_urls(object)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_urls(42)\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n    results = utils.parse.parse_urls([None, object, 42])\n    assert isinstance(results, list)\n    assert len(results) == 0\n\n\ndef test_dict_full_update():\n    \"\"\"utils: dict_full_update() testing\"\"\"\n    dict_1 = {\n        \"a\": 1,\n        \"b\": 2,\n        \"c\": 3,\n        \"d\": {\n            \"z\": 27,\n            \"y\": 26,\n            \"x\": 25,\n        },\n    }\n\n    dict_2 = {\n        \"d\": {\n            \"x\": \"updated\",\n            \"w\": 24,\n        },\n        \"c\": \"updated\",\n        \"e\": 5,\n    }\n    utils.logic.dict_full_update(dict_1, dict_2)\n    # Dictionary 2 is untouched\n    assert len(dict_2) == 3\n    assert dict_2[\"c\"] == \"updated\"\n    assert dict_2[\"d\"][\"w\"] == 24\n    assert dict_2[\"d\"][\"x\"] == \"updated\"\n    assert dict_2[\"e\"] == 5\n\n    # Dictionary 3 however has entries from Dict 2 applied\n    # without disrupting entries that were not matched.\n    assert len(dict_1) == 5\n    assert dict_1[\"a\"] == 1\n    assert dict_1[\"b\"] == 2\n    assert dict_1[\"c\"] == \"updated\"\n    assert dict_1[\"d\"][\"w\"] == 24\n    assert dict_1[\"d\"][\"x\"] == \"updated\"\n    assert dict_1[\"d\"][\"y\"] == 26\n    assert dict_1[\"d\"][\"z\"] == 27\n    assert dict_1[\"e\"] == 5\n\n\ndef test_parse_list():\n    \"\"\"utils: parse_list() testing\"\"\"\n\n    # A simple single array entry (As str)\n    results = utils.parse.parse_list(\n        \".mkv,.avi,.divx,.xvid,.mov,.wmv,.mp4,.mpg,.mpeg,.vob,.iso\"\n    )\n\n    assert results == sorted([\n        \".divx\",\n        \".iso\",\n        \".mkv\",\n        \".mov\",\n        \".mpg\",\n        \".avi\",\n        \".mpeg\",\n        \".vob\",\n        \".xvid\",\n        \".wmv\",\n        \".mp4\",\n    ])\n\n    class StrangeObject:\n        def __str__(self):\n            return \".avi\"\n\n    # Now 2 lists with lots of duplicates and other delimiters\n    results = utils.parse.parse_list(\n        \".mkv,.avi,.divx,.xvid,.mov,.wmv,.mp4,.mpg .mpeg,.vob,,; ;\",\n        (\".mkv,.avi,.divx,.xvid,.mov    \", \"    .wmv,.mp4;.mpg,.mpeg,\"),\n        \".vob,.iso\",\n        [\n            \".vob\",\n            [\n                \".vob\",\n                \".mkv\",\n                StrangeObject(),\n            ],\n        ],\n        StrangeObject(),\n    )\n\n    assert results == sorted([\n        \".divx\",\n        \".iso\",\n        \".mkv\",\n        \".mov\",\n        \".mpg\",\n        \".avi\",\n        \".mpeg\",\n        \".vob\",\n        \".xvid\",\n        \".wmv\",\n        \".mp4\",\n    ])\n\n    # Garbage in is removed\n    assert utils.parse.parse_list(object(), 42, None) == []\n\n    # Now a list with extras we want to add as strings\n    # empty entries are removed\n    results = utils.parse.parse_list(\n        [\n            \".divx\",\n            \".iso\",\n            \".mkv\",\n            \".mov\",\n            \"\",\n            \"  \",\n            \".avi\",\n            \".mpeg\",\n            \".vob\",\n            \".xvid\",\n            \".mp4\",\n        ],\n        \".mov,.wmv,.mp4,.mpg\",\n    )\n\n    assert results == sorted([\n        \".divx\",\n        \".wmv\",\n        \".iso\",\n        \".mkv\",\n        \".mov\",\n        \".mpg\",\n        \".avi\",\n        \".vob\",\n        \".xvid\",\n        \".mpeg\",\n        \".mp4\",\n    ])\n\n\ndef test_import_module(tmpdir):\n    \"\"\"utils: import_module testing\"\"\"\n    # Prepare ourselves a file to work with\n    bad_file_base = tmpdir.mkdir(\"a\")\n    bad_file = bad_file_base.join(\"README.md\")\n    bad_file.write(cleandoc(\"\"\"\n    I'm a README file, not a Python one.\n\n    I can't be loaded\n    \"\"\"))\n    assert utils.module.import_module(str(bad_file), \"invalidfile1\") is None\n    assert (\n        utils.module.import_module(str(bad_file_base), \"invalidfile2\") is None\n    )\n\n\ndef test_module_detection(tmpdir):\n    \"\"\"utils: test_module_detection() testing\"\"\"\n\n    # Clear our working variables so they don't obstruct with the tests here\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Test case where we load invalid data\n    N_MGR.module_detection(None)\n\n    # Invalid data does not load anything\n    assert len(N_MGR._paths_previously_scanned) == 0\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Prepare ourselves a file to work with\n    notify_hook_a_base = tmpdir.mkdir(\"a\")\n    notify_hook_a = notify_hook_a_base.join(\"hook.py\")\n    notify_hook_a.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    @notify(on=\"clihook\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n    \"\"\"))\n\n    notify_ignore = notify_hook_a_base.join(\"README.md\")\n    notify_ignore.write(cleandoc(\"\"\"\n    We're not a .py file, so this file gets gracefully skipped\n    \"\"\"))\n\n    # Not previously loaded\n    assert \"clihook\" not in N_MGR\n\n    # load entry by string\n    N_MGR.module_detection(str(notify_hook_a))\n    N_MGR.module_detection(str(notify_ignore))\n    N_MGR.module_detection(str(notify_hook_a_base))\n\n    assert len(N_MGR._paths_previously_scanned) == 3\n    assert len(N_MGR._custom_module_map) == 1\n\n    # Now loaded\n    assert \"clihook\" in N_MGR\n\n    # load entry by array\n    N_MGR.module_detection([str(notify_hook_a)])\n\n    # No changes to our path\n    assert len(N_MGR._paths_previously_scanned) == 3\n    assert len(N_MGR._custom_module_map) == 1\n\n    # Reset our variables for the next test\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Hidden files are ignored\n    notify_hook_b_base = tmpdir.mkdir(\"b\")\n    notify_hook_b = notify_hook_b_base.join(\".hook.py\")\n    notify_hook_b.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    # this is in a hidden file so it will not load\n    @notify(on=\"hidden\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n    \"\"\"))\n\n    assert \"hidden\" not in N_MGR\n\n    N_MGR.module_detection([str(notify_hook_b)])\n\n    # Verify that it did not load\n    assert \"hidden\" not in N_MGR\n\n    # Path was scanned; nothing loaded\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Reset our variables for the next test\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # modules with no hooks found are ignored\n    notify_hook_c_base = tmpdir.mkdir(\"c\")\n    notify_hook_c = notify_hook_c_base.join(\"empty.py\")\n    notify_hook_c.write(\"\")\n\n    N_MGR.module_detection([str(notify_hook_c)])\n\n    # File was found, no custom modules\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert len(N_MGR._custom_module_map) == 0\n\n    # A new path scanned\n    N_MGR.module_detection([str(notify_hook_c_base)])\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 0\n\n    def create_hook(tdir, cache=True, on=\"valid1\"):\n        \"\"\"Just a temporary hook creation tool for writing a working notify\n        hook.\"\"\"\n        tdir.write(cleandoc(f\"\"\"\n        from apprise.decorators import notify\n\n        # this is a good hook but burried in hidden directory which won't\n        # be accessed unless the file is pointed to via absolute path\n        @notify(on=\"{on}\")\n        def mywrapper(body, title, notify_type, *args, **kwargs):\n            pass\n        \"\"\"))\n\n        N_MGR.module_detection([str(tdir)], cache=cache)\n\n    create_hook(notify_hook_c, on=\"valid1\")\n    assert \"valid1\" not in N_MGR\n\n    # Even if we correct our empty file; the fact the directory has been\n    # scanned and failed to load (same with file), it won't be loaded\n    # a second time. This is intentional since module_detection() get's\n    # called for every AppriseAsset() object creation.  This prevents us\n    # from reloading conent over and over again wasting resources\n    assert \"valid1\" not in N_MGR\n    N_MGR.module_detection([str(notify_hook_c)])\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Even by absolute path...\n    N_MGR.module_detection([str(notify_hook_c)])\n    assert \"valid1\" not in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 0\n\n    # However we can bypass the cache if we really want to\n    N_MGR.module_detection([str(notify_hook_c_base)], cache=False)\n    assert \"valid1\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 1\n\n    # Bypassing it twice causes the module to load twice (not very efficient)\n    # However we can bypass the cache if we really want to\n    N_MGR.module_detection([str(notify_hook_c_base)], cache=False)\n    assert \"valid1\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 1\n\n    # If you update the module (corrupting it in the process and reload)\n    notify_hook_c.write(cleandoc(\"\"\"\n    raise ValueError\n    \"\"\"))\n\n    # Force no cache to cause the file to be replaced\n    N_MGR.module_detection([str(notify_hook_c_base)], cache=False)\n\n    # Our valid entry is no longer loaded\n    assert \"valid1\" not in N_MGR\n\n    # No change to scanned paths\n    assert len(N_MGR._paths_previously_scanned) == 2\n    # The previously loaded module is now gone\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Reload our valid1 entry\n    create_hook(notify_hook_c, on=\"valid1\", cache=False)\n    assert \"valid1\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 1\n\n    # Prepare an empty file\n    notify_hook_c.write(\"\")\n    N_MGR.module_detection([str(notify_hook_c_base)], cache=False)\n\n    # Our valid entry is no longer loaded\n    assert \"valid1\" not in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Now reload our module again (this time rather then an exception, the\n    # module is read back and swaps `valid1` for `valid2`\n    create_hook(notify_hook_c, on=\"valid1\", cache=False)\n    assert \"valid1\" in N_MGR\n    assert \"valid2\" not in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 1\n\n    create_hook(notify_hook_c, on=\"valid2\", cache=False)\n    assert \"valid1\" not in N_MGR\n    assert \"valid2\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 1\n\n    # Reset our variables for the next test\n    create_hook(notify_hook_c, on=\"valid1\", cache=False)\n    del N_MGR[\"valid1\"]\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    notify_hook_d = notify_hook_c_base.join(\".ignore.py\")\n    notify_hook_d.write(\"\")\n    notify_hook_e_base = notify_hook_c_base.mkdir(\".ignore\")\n    notify_hook_e = notify_hook_e_base.join(\"__init__.py\")\n    notify_hook_e.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    # this is a good hook but burried in hidden directory which won't\n    # be accessed unless the file is pointed to via absolute path\n    @notify(on=\"valid2\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n    \"\"\"))\n\n    # Try to load our base directory again; this time we search by the\n    # directory; the only edge case we're testing here is it will not\n    # even look at the .ignore.py file found since it is invalid\n    N_MGR.module_detection([str(notify_hook_c_base)])\n    assert \"valid1\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert len(N_MGR._custom_module_map) == 1\n\n    # Reset our variables for the next test\n    del N_MGR._schema_map[\"valid1\"]\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Try to load our base directory again\n    N_MGR.module_detection([str(notify_hook_c)])\n    assert \"valid1\" in N_MGR\n\n    # Hidden directories are not scanned\n    assert \"valid2\" not in N_MGR\n\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert str(notify_hook_c) in N_MGR._paths_previously_scanned\n    assert len(N_MGR._custom_module_map) == 1\n\n    # However a direct reference to the hidden directory is okay\n    N_MGR.module_detection([str(notify_hook_e_base)])\n\n    # We loaded our module\n    assert \"valid2\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 3\n    assert str(notify_hook_c) in N_MGR._paths_previously_scanned\n    assert str(notify_hook_e) in N_MGR._paths_previously_scanned\n    assert str(notify_hook_e_base) in N_MGR._paths_previously_scanned\n    assert len(N_MGR._custom_module_map) == 2\n\n    # Reset our variables for the next test\n    del N_MGR._schema_map[\"valid1\"]\n    del N_MGR._schema_map[\"valid2\"]\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Load our file directly\n    assert \"valid2\" not in N_MGR\n    N_MGR.module_detection([str(notify_hook_e)])\n\n    # Now we have it loaded as expected\n    assert \"valid2\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert str(notify_hook_e) in N_MGR._paths_previously_scanned\n    assert len(N_MGR._custom_module_map) == 1\n\n    # however if we try to load the base directory where the __init__.py\n    # was already loaded from, it will not change anything\n    N_MGR.module_detection([str(notify_hook_e_base)])\n    assert \"valid2\" in N_MGR\n    assert len(N_MGR._paths_previously_scanned) == 2\n    assert str(notify_hook_e) in N_MGR._paths_previously_scanned\n    assert str(notify_hook_e_base) in N_MGR._paths_previously_scanned\n    assert len(N_MGR._custom_module_map) == 1\n\n    # Tidy up for the next test\n    del N_MGR._schema_map[\"valid2\"]\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    assert \"valid1\" not in N_MGR\n    assert \"valid2\" not in N_MGR\n    assert \"valid3\" not in N_MGR\n    notify_hook_f_base = tmpdir.mkdir(\"f\")\n    notify_hook_f = notify_hook_f_base.join(\"invalid.py\")\n    notify_hook_f.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    # A very invalid hook type... on should not be None\n    @notify(on=None)\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n\n    # An invalid name\n    @notify(on='valid1', name=None)\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n\n    # Another invalid name (so it's ignored)\n    @notify(on='valid2', name=object)\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n\n    # Simply put... the name has to be a string to be referenced\n    # however this will still be loaded\n    @notify(on='valid3', name=4)\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        pass\n\n    \"\"\"))\n\n    N_MGR.module_detection([str(notify_hook_f)])\n\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert len(N_MGR._custom_module_map) == 1\n    assert \"valid1\" in N_MGR\n    assert \"valid2\" in N_MGR\n    assert \"valid3\" in N_MGR\n\n    # Reset our variables for the next test\n    del N_MGR._schema_map[\"valid1\"]\n    del N_MGR._schema_map[\"valid2\"]\n    del N_MGR._schema_map[\"valid3\"]\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n    # Now test the handling of just bad data entirely\n    notify_hook_g_base = tmpdir.mkdir(\"g\")\n    notify_hook_g = notify_hook_g_base.join(\"binary.py\")\n    with open(str(notify_hook_g), \"wb\") as fout:\n        fout.write(os.urandom(512))\n\n    N_MGR.module_detection([str(notify_hook_g)])\n    assert len(N_MGR._paths_previously_scanned) == 1\n    assert len(N_MGR._custom_module_map) == 0\n\n    # Reset our variables before we leave\n    N_MGR._paths_previously_scanned.clear()\n    N_MGR._custom_module_map.clear()\n\n\ndef test_exclusive_match():\n    \"\"\"utils: is_exclusive_match() testing\"\"\"\n\n    # No Logic always returns True if there is also no data\n    assert utils.logic.is_exclusive_match(data=None, logic=None) is True\n    assert utils.logic.is_exclusive_match(data=None, logic=set()) is True\n    assert utils.logic.is_exclusive_match(data=\"\", logic=set()) is True\n    assert utils.logic.is_exclusive_match(data=\"\", logic=set()) is True\n\n    # however, once data is introduced, True is no longer returned\n    # if no logic has been specified\n    assert utils.logic.is_exclusive_match(data=\"check\", logic=set()) is False\n    assert (\n        utils.logic.is_exclusive_match(data=[\"check\", \"checkb\"], logic=set())\n        is False\n    )\n\n    # String delimters are stripped out so that a list can be formed\n    # the below is just an empty token list\n    assert utils.logic.is_exclusive_match(data=set(), logic=\",;   ,\") is True\n\n    # garbage logic is never an exclusive match\n    assert utils.logic.is_exclusive_match(data=set(), logic=object()) is False\n    assert (\n        utils.logic.is_exclusive_match(\n            data=set(),\n            logic=[\n                object(),\n            ],\n        )\n        is False\n    )\n\n    #\n    # Test with logic:\n    #\n    data = {\"abc\"}\n\n    # def in data\n    assert utils.logic.is_exclusive_match(logic=\"def\", data=data) is False\n    # def in data\n    assert (\n        utils.logic.is_exclusive_match(\n            logic=[\n                \"def\",\n            ],\n            data=data,\n        )\n        is False\n    )\n    # def in data\n    assert utils.logic.is_exclusive_match(logic=(\"def\",), data=data) is False\n    # def in data\n    assert (\n        utils.logic.is_exclusive_match(\n            logic={\n                \"def\",\n            },\n            data=data,\n        )\n        is False\n    )\n    # abc in data\n    assert (\n        utils.logic.is_exclusive_match(\n            logic=[\n                \"abc\",\n            ],\n            data=data,\n        )\n        is True\n    )\n    # abc in data\n    assert utils.logic.is_exclusive_match(logic=(\"abc\",), data=data) is True\n    # abc in data\n    assert (\n        utils.logic.is_exclusive_match(\n            logic={\n                \"abc\",\n            },\n            data=data,\n        )\n        is True\n    )\n    # abc or def in data\n    assert utils.logic.is_exclusive_match(logic=\"abc, def\", data=data) is True\n\n    #\n    # Update our data set so we can do more advance checks\n    #\n    data = {\"abc\", \"def\", \"efg\", \"xyz\"}\n\n    # match_all matches everything\n    assert utils.logic.is_exclusive_match(logic=\"all\", data=data) is True\n    assert utils.logic.is_exclusive_match(logic=[\"all\"], data=data) is True\n\n    # def and abc in data\n    assert (\n        utils.logic.is_exclusive_match(logic=[(\"abc\", \"def\")], data=data)\n        is True\n    )\n\n    # cba and abc in data\n    assert (\n        utils.logic.is_exclusive_match(logic=[(\"cba\", \"abc\")], data=data)\n        is False\n    )\n\n    # www or zzz or abc and xyz\n    assert (\n        utils.logic.is_exclusive_match(\n            logic=[\"www\", \"zzz\", (\"abc\", \"xyz\")], data=data\n        )\n        is True\n    )\n    # www or zzz or abc and xyz (strings are valid too)\n    assert (\n        utils.logic.is_exclusive_match(\n            logic=[\"www\", \"zzz\", \"abc, xyz\"], data=data\n        )\n        is True\n    )\n\n    # www or zzz or abc and jjj\n    assert (\n        utils.logic.is_exclusive_match(\n            logic=[\"www\", \"zzz\", (\"abc\", \"jjj\")], data=data\n        )\n        is False\n    )\n\n    #\n    # Empty data set\n    #\n    data = set()\n    assert utils.logic.is_exclusive_match(logic=[\"www\"], data=data) is False\n    assert utils.logic.is_exclusive_match(logic=\"all\", data=data) is True\n\n    #\n    # Update our data set so we can do more advance checks\n    #\n    data = {\"always\", \"entry1\"}\n    # We'll always match on the with keyword always\n    assert utils.logic.is_exclusive_match(logic=\"always\", data=data) is True\n    assert utils.logic.is_exclusive_match(logic=\"garbage\", data=data) is True\n    # However we will not match if we turn this feature off\n    assert (\n        utils.logic.is_exclusive_match(\n            logic=\"garbage\", data=data, match_always=False\n        )\n        is False\n    )\n\n    # Change default value from 'all' to 'match_me'. Logic matches\n    # so we pass\n    assert (\n        utils.logic.is_exclusive_match(\n            logic=\"match_me\", data=data, match_all=\"match_me\"\n        )\n        is True\n    )\n\n\ndef test_apprise_validate_regex():\n    \"\"\"\n    API: Apprise() Validate Regex tests\n\n    \"\"\"\n    assert utils.parse.validate_regex(None) is None\n    assert utils.parse.validate_regex(object) is None\n    assert utils.parse.validate_regex(42) is None\n    assert utils.parse.validate_regex(\"\") is None\n    assert utils.parse.validate_regex(\"  \") is None\n    assert utils.parse.validate_regex(\"abc\") == \"abc\"\n\n    # value is a keyword that is extracted (if found)\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[^-]+)-\", fmt=\"{value}\"\n        )\n        == \"abcd\"\n    )\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[^-]+)-\", strip=False, fmt=\"{value}\"\n        )\n        == \" abcd \"\n    )\n\n    # String flags supported in addition to numeric\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[^-]+)-\", \"i\", fmt=\"{value}\"\n        )\n        == \"abcd\"\n    )\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[^-]+)-\", re.I, fmt=\"{value}\"\n        )\n        == \"abcd\"\n    )\n\n    # Test multiple flag settings\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[^-]+)-\", \"isax\", fmt=\"{value}\"\n        )\n        == \"abcd\"\n    )\n\n    # Invalid flags are just ignored. The below fails to match\n    # because the default value of 'i' is over-ridden by what is\n    # identfied below, and no flag is set at the end of the day\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[ABCD]+)-\", \"-%2gb\", fmt=\"{value}\"\n        )\n        is None\n    )\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[ABCD]+)-\", \"\", fmt=\"{value}\"\n        )\n        is None\n    )\n    assert (\n        utils.parse.validate_regex(\n            \"- abcd -\", r\"-(?P<value>[ABCD]+)-\", None, fmt=\"{value}\"\n        )\n        is None\n    )\n\n\ndef test_apply_templating():\n    \"\"\"utils: apply_template() testing\"\"\"\n\n    template = \"Hello {{fname}}, How are you {{whence}}?\"\n\n    result = utils.templates.apply_template(\n        template, **{\"fname\": \"Chris\", \"whence\": \"this morning\"}\n    )\n    assert isinstance(result, str)\n    assert result == \"Hello Chris, How are you this morning?\"\n\n    # In this example 'whence' isn't provided, so it isn't swapped\n    result = utils.templates.apply_template(template, **{\"fname\": \"Chris\"})\n    assert isinstance(result, str)\n    assert result == \"Hello Chris, How are you {{whence}}?\"\n\n    # white space won't cause any ill affects:\n    template = \"Hello {{ fname }}, How are you {{   whence}}?\"\n    result = utils.templates.apply_template(\n        template, **{\"fname\": \"Chris\", \"whence\": \"this morning\"}\n    )\n    assert isinstance(result, str)\n    assert result == \"Hello Chris, How are you this morning?\"\n\n    # No arguments won't cause any problems\n    template = \"Hello {{fname}}, How are you {{whence}}?\"\n    result = utils.templates.apply_template(template)\n    assert isinstance(result, str)\n    assert result == template\n\n    # Wrong elements are simply ignored\n    result = utils.templates.apply_template(\n        template, **{\"fname\": \"l2g\", \"whence\": \"this evening\", \"ignore\": \"me\"}\n    )\n    assert isinstance(result, str)\n    assert result == \"Hello l2g, How are you this evening?\"\n\n    # Empty template makes things easy\n    result = utils.templates.apply_template(\n        \"\", **{\"fname\": \"l2g\", \"whence\": \"this evening\"}\n    )\n    assert isinstance(result, str)\n    assert result == \"\"\n\n    # Regular expressions are safely escapped and act as normal\n    # tokens:\n    template = \"Hello {{.*}}, How are you {{[A-Z0-9]+}}?\"\n    result = utils.templates.apply_template(\n        template, **{\".*\": \"l2g\", \"[A-Z0-9]+\": \"this afternoon\"}\n    )\n    assert result == \"Hello l2g, How are you this afternoon?\"\n\n    # JSON is handled too such as escaping quotes\n    template = '{value: \"{{ value }}\"}'\n    result = utils.templates.apply_template(\n        template,\n        app_mode=utils.templates.TemplateType.JSON,\n        **{\"value\": '\"quotes are escaped\"'},\n    )\n    assert result == '{value: \"\\\\\"quotes are escaped\\\\\"\"}'\n\n\ndef test_cwe312_word():\n    \"\"\"utils: cwe312_word() testing\"\"\"\n    assert utils.cwe312.cwe312_word(None) is None\n    assert utils.cwe312.cwe312_word(42) == 42\n    assert utils.cwe312.cwe312_word(\"\") == \"\"\n    assert utils.cwe312.cwe312_word(\" \") == \" \"\n    assert utils.cwe312.cwe312_word(\"!\") == \"!\"\n\n    assert utils.cwe312.cwe312_word(\"a\") == \"a\"\n    assert utils.cwe312.cwe312_word(\"ab\") == \"ab\"\n    assert utils.cwe312.cwe312_word(\"abc\") == \"abc\"\n    assert utils.cwe312.cwe312_word(\"abcd\") == \"abcd\"\n    assert utils.cwe312.cwe312_word(\"abcd\", force=True) == \"a...d\"\n\n    assert utils.cwe312.cwe312_word(\"abc--d\") == \"abc--d\"\n    assert utils.cwe312.cwe312_word(\"a-domain.ca\") == \"a...a\"\n\n    # Variances to still catch domain\n    assert (\n        utils.cwe312.cwe312_word(\"a-domain.ca\", advanced=False)\n        == \"a-domain.ca\"\n    )\n    assert (\n        utils.cwe312.cwe312_word(\"a-domain.ca\", threshold=6) == \"a-domain.ca\"\n    )\n\n\ndef test_cwe312_url():\n    \"\"\"utils: cwe312_url() testing\"\"\"\n    assert utils.cwe312.cwe312_url(None) is None\n    assert utils.cwe312.cwe312_url(42) == 42\n    assert utils.cwe312.cwe312_url(\"http://\") == \"http://\"\n    assert utils.cwe312.cwe312_url(\"discord://\") == \"discord://\"\n    assert utils.cwe312.cwe312_url(\"path\") == \"http://path\"\n    assert utils.cwe312.cwe312_url(\"path/\") == \"http://path/\"\n\n    # Now test http:// private data\n    assert (\n        utils.cwe312.cwe312_url(\"http://user:pass123@localhost\")\n        == \"http://user:p...3@localhost\"\n    )\n    assert (\n        utils.cwe312.cwe312_url(\"http://user@localhost\")\n        == \"http://user@localhost\"\n    )\n    assert (\n        utils.cwe312.cwe312_url(\"http://user@localhost?password=abc123\")\n        == \"http://user@localhost?password=a...3\"\n    )\n    assert (\n        utils.cwe312.cwe312_url(\"http://user@localhost?secret=secret-.12345\")\n        == \"http://user@localhost?secret=s...5\"\n    )\n\n    assert (\n        utils.cwe312.cwe312_url(\n            \"slack://mybot@xoxb-43598234231-3248932482278\"\n            \"-BZK5Wj15B9mPh1RkShJoCZ44\"\n            \"/lead2gold@gmail.com\"\n        )\n        == \"slack://mybot@x...4/l...m\"\n    )\n    assert (\n        utils.cwe312.cwe312_url(\n            \"slack://test@B4QP3WWB4/J3QWT41JM/XIl2ffpqXkzkwMXrJdevi7W3/#random\"\n        )\n        == \"slack://test@B...4/J...M/X...3/\"\n    )\n\n\ndef test_base64_encode_decode():\n    \"\"\"Utils:Base64:URLEncode & Decode.\"\"\"\n    assert utils.base64.base64_urlencode(None) is None\n    assert utils.base64.base64_urlencode(42) is None\n    assert utils.base64.base64_urlencode(object) is None\n    assert utils.base64.base64_urlencode({}) is None\n    assert utils.base64.base64_urlencode(\"\") is None\n    assert utils.base64.base64_urlencode(\"abc\") is None\n    assert utils.base64.base64_urlencode(b\"\") == \"\"\n    assert utils.base64.base64_urlencode(b\"abc\") == \"YWJj\"\n\n    assert utils.base64.base64_urldecode(None) is None\n    assert utils.base64.base64_urldecode(42) is None\n    assert utils.base64.base64_urldecode(object) is None\n    assert utils.base64.base64_urldecode({}) is None\n\n    assert utils.base64.base64_urldecode(\"abc\") == b\"i\\xb7\"\n    assert utils.base64.base64_urldecode(\"\") == b\"\"\n    assert utils.base64.base64_urldecode(\"YWJj\") == b\"abc\"\n\n\ndef test_dict_base64_codec(tmpdir):\n    \"\"\"Test encoding/decoding of base64 content.\"\"\"\n    original = {\n        \"int\": 1,\n        \"float\": 2.3,\n    }\n\n    encoded, needs_decoding = utils.base64.encode_b64_dict(original)\n    assert encoded == {\"int\": \"b64:MQ==\", \"float\": \"b64:Mi4z\"}\n    assert needs_decoding is True\n    decoded = utils.base64.decode_b64_dict(encoded)\n    assert decoded == original\n\n    with mock.patch(\"json.dumps\", side_effect=TypeError()):\n        encoded, needs_decoding = utils.base64.encode_b64_dict(original)\n        # we failed\n        assert needs_decoding is False\n        assert encoded == {\n            \"int\": \"1\",\n            \"float\": \"2.3\",\n        }\n\n\ndef test_dir_size(tmpdir):\n    \"\"\"Test dir size tool.\"\"\"\n\n    # Nothing to find/see\n    size, errors = utils.disk.dir_size(str(tmpdir))\n    assert size == 0\n    assert len(errors) == 0\n\n    # Write a file in our root directory\n    tmpdir.join(\"root.psdata\").write(\"0\" * 1024 * 1024)\n\n    # Prepare some more directories\n    namespace_1 = tmpdir.mkdir(\"abcdefg\")\n    namespace_2 = tmpdir.mkdir(\"defghij\")\n    namespace_2.join(\"cache.psdata\").write(\"0\" * 1024 * 1024)\n    size, errors = utils.disk.dir_size(str(tmpdir))\n    assert size == 1024 * 1024 * 2\n    assert len(errors) == 0\n\n    # Write another file\n    namespace_1.join(\"cache.psdata\").write(\"0\" * 1024 * 1024)\n    size, errors = utils.disk.dir_size(str(tmpdir))\n    assert size == 1024 * 1024 * 3\n    assert len(errors) == 0\n\n    size, errors = utils.disk.dir_size(str(namespace_1))\n    assert size == 1024 * 1024\n    assert len(errors) == 0\n\n    # Create a directory insde one of our namespaces\n    subspace_1 = namespace_1.mkdir(\"zyx\")\n    size, errors = utils.disk.dir_size(str(namespace_1))\n    assert size == 1024 * 1024\n\n    subspace_1.join(\"cache.psdata\").write(\"0\" * 1024 * 1024)\n    size, errors = utils.disk.dir_size(str(tmpdir))\n    assert size == 1024 * 1024 * 4\n    assert len(errors) == 0\n\n    # Recursion limit reduced... no change at 2 as we can go 2\n    # diretories deep no problem\n    size, errors = utils.disk.dir_size(str(tmpdir), max_depth=2)\n    assert size == 1024 * 1024 * 4\n    assert len(errors) == 0\n\n    size, errors = utils.disk.dir_size(str(tmpdir), max_depth=1)\n    assert size == 1024 * 1024 * 3\n    # we can't get into our subspace_1\n    assert len(errors) == 1\n    assert str(subspace_1) in errors\n\n    size, errors = utils.disk.dir_size(str(tmpdir), max_depth=0)\n    assert size == 1024 * 1024\n    # we can't get into our namespace directories\n    assert len(errors) == 2\n    assert str(namespace_1) in errors\n    assert str(namespace_2) in errors\n\n    # Let's cause problems now and test the output\n    size, errors = utils.disk.dir_size(\"invalid-directory\", missing_okay=True)\n    assert size == 0\n    assert len(errors) == 0\n\n    size, errors = utils.disk.dir_size(\n        \"invalid-directory\", missing_okay=False\n    )\n    assert size == 0\n    assert len(errors) == 1\n    assert \"invalid-directory\" in errors\n\n    with mock.patch(\"os.scandir\", side_effect=OSError()):\n        size, errors = utils.disk.dir_size(str(tmpdir), missing_okay=True)\n        assert size == 0\n        assert len(errors) == 1\n        assert str(tmpdir) in errors\n\n    with mock.patch(\"os.scandir\") as mock_scandir:\n        mock_entry = mock.MagicMock()\n        mock_entry.is_file.side_effect = OSError()\n        mock_entry.path = \"/test/path\"\n        # Mock the scandir return value to yield the mock entry\n        mock_scandir.return_value.__enter__.return_value = [mock_entry]\n\n        size, errors = utils.disk.dir_size(str(tmpdir))\n        assert size == 0\n        assert len(errors) == 1\n        assert mock_entry.path in errors\n\n    with mock.patch(\"os.scandir\") as mock_scandir:\n        mock_entry = mock.MagicMock()\n        mock_entry.is_file.return_value = False\n        mock_entry.is_dir.side_effect = OSError()\n        mock_entry.path = \"/test/path\"\n        # Mock the scandir return value to yield the mock entry\n        mock_scandir.return_value.__enter__.return_value = [mock_entry]\n        size, errors = utils.disk.dir_size(str(tmpdir))\n        assert len(errors) == 1\n        assert mock_entry.path in errors\n\n    with mock.patch(\"os.scandir\") as mock_scandir:\n        mock_entry = mock.MagicMock()\n        mock_entry.is_file.return_value = False\n        mock_entry.is_dir.return_value = False\n        # Mock the scandir return value to yield the mock entry\n        mock_scandir.return_value.__enter__.return_value = [mock_entry]\n        size, errors = utils.disk.dir_size(str(tmpdir))\n        assert size == 0\n        assert len(errors) == 0\n\n    with mock.patch(\"os.scandir\") as mock_scandir:\n        mock_entry = mock.MagicMock()\n        mock_entry.is_file.side_effect = FileNotFoundError()\n        mock_entry.path = \"/test/path\"\n        # Mock the scandir return value to yield the mock entry\n        mock_scandir.return_value.__enter__.return_value = [mock_entry]\n\n        size, errors = utils.disk.dir_size(str(tmpdir))\n        assert size == 0\n        # No file isn't a problem, we're calculating disksize anyway,\n        # one less thing to calculate\n        assert len(errors) == 0\n\n\ndef test_bytes_to_str():\n    \"\"\"Test Bytes to String representation.\"\"\"\n    # Garbage Entry\n    assert utils.disk.bytes_to_str(None) is None\n    assert utils.disk.bytes_to_str(\"\") is None\n    assert utils.disk.bytes_to_str(\"GARBAGE\") is None\n\n    # Good Entries\n    assert utils.disk.bytes_to_str(0) == \"0.00B\"\n    assert utils.disk.bytes_to_str(1) == \"1.00B\"\n    assert utils.disk.bytes_to_str(1.1) == \"1.10B\"\n    assert utils.disk.bytes_to_str(1024) == \"1.00KB\"\n    assert utils.disk.bytes_to_str(1024 * 1024) == \"1.00MB\"\n    assert utils.disk.bytes_to_str(1024 * 1024 * 1024) == \"1.00GB\"\n    assert utils.disk.bytes_to_str(1024 * 1024 * 1024 * 1024) == \"1.00TB\"\n\n    # Support strings too\n    assert utils.disk.bytes_to_str(\"0\") == \"0.00B\"\n    assert utils.disk.bytes_to_str(\"1024\") == \"1.00KB\"\n\n\ndef test_time_zoneinfo():\n    \"\"\"utils: zoneinfo() testing\"\"\"\n\n    # Some valid strings\n    assert isinstance(utils.time.zoneinfo(\"UTC\"), tzinfo)\n    assert isinstance(utils.time.zoneinfo(\"z\"), tzinfo)\n    assert isinstance(utils.time.zoneinfo(\"gmt\"), tzinfo)\n    assert isinstance(utils.time.zoneinfo(\"utc\"), tzinfo)\n    assert isinstance(utils.time.zoneinfo(\"Toronto\"), tzinfo)\n    assert isinstance(utils.time.zoneinfo(\"America/Toronto\"), tzinfo)\n    assert isinstance(utils.time.zoneinfo(\"america/toronto\"), tzinfo)\n\n    # Edge Case with time also supported:\n    tz = utils.time.zoneinfo(\"America/Argentina/Cordoba\")\n    isinstance(tz, tzinfo)\n    assert isinstance(utils.time.zoneinfo(\"Argentina/Cordoba\"), tzinfo)\n    assert utils.time.zoneinfo(\"Argentina/Cordoba\").key == tz.key\n    # \"America/Cordoba\" has been obsoleted by IANA in facor of\n    #  \"America/Argentina/Cordoba\", however the IANA database\n    #  instance used is system-dependent, so these tests have\n    #  different results depending on the system running them\n    if utils.time.zoneinfo(\"Cordoba\") is not None:\n        # the system has the obsolete \"America/Cordoba\" entry\n        assert isinstance(utils.time.zoneinfo(\"Cordoba\"), tzinfo)\n        assert utils.time.zoneinfo(\"Cordoba\").key == \"America/Cordoba\"\n    else:\n        assert utils.time.zoneinfo(\"Cordoba\") is None\n        # the utils helper should still resolve this abbreviated (and\n        #  lowercase) form\n        assert utils.time.zoneinfo(\"argentina/cordoba\").key == \\\n            \"America/Argentina/Cordoba\"\n\n    # Too ambiguous\n    assert utils.time.zoneinfo(\"Argentina\") is None\n\n    # bad data\n    assert utils.time.zoneinfo(object) is None\n    assert utils.time.zoneinfo(None) is None\n    assert utils.time.zoneinfo(1) is None\n    assert utils.time.zoneinfo(\"\") is None\n    assert utils.time.zoneinfo(\"invalid\") is None\n"
  },
  {
    "path": "tests/test_asyncio.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport sys\n\nimport pytest\n\nfrom apprise import Apprise, NotificationManager, NotifyBase, NotifyFormat\n\nlogging.disable(logging.CRITICAL)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\n@pytest.mark.skipif(\n    sys.version_info >= (3, 7), reason=\"Requires Python 3.0 to 3.6\"\n)\ndef test_apprise_asyncio_runtime_error():\n    \"\"\"\n    API: Apprise() AsyncIO RuntimeError handling\n\n    \"\"\"\n\n    class GoodNotification(NotifyBase):\n        def __init__(self, **kwargs):\n            super().__init__(notify_format=NotifyFormat.HTML, **kwargs)\n\n        def url(self, **kwargs):\n            # Support URL\n            return \"\"\n\n        def send(self, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        @staticmethod\n        def parse_url(url, *args, **kwargs):\n            # always parseable\n            return NotifyBase.parse_url(url, verify_host=False)\n\n    # Store our good notification in our schema map\n    N_MGR[\"good\"] = GoodNotification\n\n    # Create ourselves an Apprise object\n    a = Apprise()\n\n    # Add a few entries\n    for _ in range(25):\n        a.add(\"good://\")\n\n    # Python v3.6 and lower can't handle situations gracefully when an\n    # event_loop isn't already established(). Test that Apprise can handle\n    # these situations\n    import asyncio\n\n    # Get our event loop\n    try:\n        loop = asyncio.get_event_loop()\n\n    except RuntimeError:\n        loop = None\n\n    # Adjust out event loop to not point at anything\n    asyncio.set_event_loop(None)\n\n    # With the event loop inactive, we'll fail trying to get the active loop\n    with pytest.raises(RuntimeError):\n        asyncio.get_event_loop()\n\n    try:\n        # Below, we internally will throw a RuntimeError() since there will\n        # be no active event_loop in place. However internally it will be smart\n        # enough to create a new event loop and continue...\n        assert a.notify(title=\"title\", body=\"body\") is True\n\n    finally:\n        # Restore our event loop (in the event the above test failed)\n        asyncio.set_event_loop(loop)\n"
  },
  {
    "path": "tests/test_attach_base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise.attachment.base import AttachBase\n\nlogging.disable(logging.CRITICAL)\n\n\ndef test_mimetype_initialization():\n    \"\"\"\n    API: AttachBase() mimetype initialization\n\n    \"\"\"\n    with (\n        mock.patch(\"mimetypes.init\") as mock_init,\n        mock.patch(\"mimetypes.inited\", True),\n    ):\n        AttachBase()\n        assert mock_init.call_count == 0\n\n    with (\n        mock.patch(\"mimetypes.init\") as mock_init,\n        mock.patch(\"mimetypes.inited\", False),\n    ):\n        AttachBase()\n        assert mock_init.call_count == 1\n\n\ndef test_attach_base():\n    \"\"\"\n    API: AttachBase()\n\n    \"\"\"\n    # an invalid mime-type\n    with pytest.raises(TypeError):\n        AttachBase(**{\"mimetype\": \"invalid\"})\n\n    # a valid mime-type does not cause an exception to throw\n    AttachBase(**{\"mimetype\": \"image/png\"})\n\n    # Create an object with no mimetype over-ride\n    obj = AttachBase()\n\n    # Get our url object\n    str(obj)\n\n    # We can not process name/path/mimetype at a Base level\n    with pytest.raises(NotImplementedError):\n        obj.download()\n\n    # Unsupported URLs are not parsed\n    assert AttachBase.parse_url(url=\"invalid://\") is None\n\n    # Valid URL & Valid Format\n    results = AttachBase.parse_url(url=\"file://relative/path\")\n    assert isinstance(results, dict)\n    # No mime is defined\n    assert results.get(\"mimetype\") is None\n\n    # Valid URL & Valid Format with mime type set\n    results = AttachBase.parse_url(url=\"file://relative/path?mime=image/jpeg\")\n    assert isinstance(results, dict)\n    # mime defined\n    assert results.get(\"mimetype\") == \"image/jpeg\"\n    # We can retrieve our url\n    assert str(results)\n"
  },
  {
    "path": "tests/test_attach_file.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom os.path import dirname, join\nimport re\nimport time\nfrom unittest import mock\nimport urllib\n\nimport pytest\n\nfrom apprise import AppriseAttachment, exception\nfrom apprise.attachment.base import AttachBase\nfrom apprise.attachment.file import AttachFile\nfrom apprise.common import ContentLocation\n\nlogging.disable(logging.CRITICAL)\n\nTEST_VAR_DIR = join(dirname(__file__), \"var\")\n\n\ndef test_attach_file_parse_url():\n    \"\"\"\n    API: AttachFile().parse_url()\n\n    \"\"\"\n\n    # bad entry\n    assert AttachFile.parse_url(\"garbage://\") is None\n\n    # no file path specified\n    assert AttachFile.parse_url(\"file://\") is None\n\n\ndef test_file_expiry(tmpdir):\n    \"\"\"\n    API: AttachFile Expiry\n    \"\"\"\n    path = join(TEST_VAR_DIR, \"apprise-test.gif\")\n    image = tmpdir.mkdir(\"apprise_file\").join(\"test.jpg\")\n    with open(path, \"rb\") as data:\n        image.write(data)\n\n    aa = AppriseAttachment.instantiate(str(image), cache=30)\n\n    # Our file is now available\n    assert aa.exists()\n\n    # Our second call has the file already downloaded, but now compares\n    # it's date against when we consider it to have expire.  We're well\n    # under 30 seconds here (our set value), so this will succeed\n    assert aa.exists()\n\n    with mock.patch(\"time.time\", return_value=time.time() + 31):\n        # This will force a re-download as our cache will have\n        # expired\n        assert aa.exists()\n\n    with mock.patch(\"time.time\", side_effect=OSError):\n        # We will throw an exception\n        assert aa.exists()\n\n\ndef test_attach_mimetype():\n    \"\"\"\n    API: AttachFile MimeType()\n\n    \"\"\"\n    # Simple gif test\n    path = join(TEST_VAR_DIR, \"apprise-test.gif\")\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    assert response.path == path\n    assert response.name == \"apprise-test.gif\"\n    assert response.mimetype == \"image/gif\"\n\n    # Force mimetype\n    response._mimetype = None\n    response.detected_mimetype = None\n\n    assert response.mimetype == \"image/gif\"\n\n    response._mimetype = None\n    response.detected_mimetype = None\n    with mock.patch(\"mimetypes.guess_type\", side_effect=TypeError):\n        assert response.mimetype == \"application/octet-stream\"\n\n\ndef test_attach_file():\n    \"\"\"\n    API: AttachFile()\n\n    \"\"\"\n    # Simple gif test\n    path = join(TEST_VAR_DIR, \"apprise-test.gif\")\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    assert response.path == path\n    assert response.name == \"apprise-test.gif\"\n    assert response.mimetype == \"image/gif\"\n    # Download is successful and has already been called by now; below pulls\n    # results from cache\n    assert response.download()\n\n    with mock.patch(\"os.path.isfile\", side_effect=OSError):\n        assert response.exists() is False\n\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        assert response.exists() is False\n\n    # Test that our file exists\n    assert response.exists() is True\n    response.cache = True\n    # Leverage always-cached flag\n    assert response.exists() is True\n\n    # On Windows, it is:\n    #  `file://D%3A%5Ca%5Capprise%5Capprise%5Ctest%5Cvar%5Capprise-test.gif`\n    path_in_url = urllib.parse.quote(path)\n    assert response.url().startswith(f\"file://{path_in_url}\")\n\n    # No mime-type and/or filename over-ride was specified, so it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", response.url()) is None\n    assert re.search(r\"[?&]name=\", response.url()) is None\n\n    # Test case where location is simply set to INACCESSIBLE\n    # Below is a bad example, but it proves the section of code properly works.\n    # Ideally a server admin may wish to just disable all File based\n    # attachments entirely. In this case, they simply just need to change the\n    # global singleton at the start of their program like:\n    #\n    # import apprise\n    # apprise.attachment.AttachFile.location = \\\n    #       apprise.ContentLocation.INACCESSIBLE\n    #\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    response.location = ContentLocation.INACCESSIBLE\n    assert response.path is None\n    # Downloads just don't work period\n    assert response.download() is False\n\n    # File handling (even if image is set to maxium allowable)\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    with mock.patch(\"os.path.getsize\", return_value=AttachBase.max_file_size):\n        # It will still work\n        assert response.path == path\n\n    # File handling when size is to large\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    with mock.patch(\n        \"os.path.getsize\", return_value=AttachBase.max_file_size + 1\n    ):\n        # We can't work in this case\n        assert response.path is None\n\n    # File handling when image is not available\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        # This triggers a full check and will fail the isfile() check\n        assert response.path is None\n\n    # The call to AttachBase.path automatically triggers a call to download()\n    # but this same is done with a call to AttachBase.name as well.  Above\n    # test cases reference 'path' right after instantiation; here we reference\n    # 'name'\n    response = AppriseAttachment.instantiate(path)\n    assert response.name == \"apprise-test.gif\"\n    assert response.path == path\n    assert response.mimetype == \"image/gif\"\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", response.url()) is None\n    assert re.search(r\"[?&]name=\", response.url()) is None\n\n    # continuation to cheking 'name' instead of 'path' first where our call\n    # to download() fails\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        # This triggers a full check and will fail the isfile() check\n        assert response.name is None\n\n    # The call to AttachBase.path automatically triggers a call to download()\n    # but this same is done with a call to AttachBase.mimetype as well.  Above\n    # test cases reference 'path' right after instantiation; here we reference\n    # 'mimetype'\n    response = AppriseAttachment.instantiate(path)\n    assert response.mimetype == \"image/gif\"\n    assert response.name == \"apprise-test.gif\"\n    assert response.path == path\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", response.url()) is None\n    assert re.search(r\"[?&]name=\", response.url()) is None\n\n    # continuation to cheking 'name' instead of 'path' first where our call\n    # to download() fails\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        # download() fails so we don't have a mimetpe\n        assert response.mimetype is None\n        assert response.name is None\n        assert response.path is None\n        # This triggers a full check and will fail the isfile() check\n\n    # Force a mime-type and new name\n    response = AppriseAttachment.instantiate(\n        \"file://{}?mime={}&name={}\".format(path, \"image/jpeg\", \"test.jpeg\")\n    )\n    assert isinstance(response, AttachFile)\n    assert response.path == path\n    assert response.name == \"test.jpeg\"\n    assert response.mimetype == \"image/jpeg\"\n    assert re.search(r\"[?&]mime=image/jpeg\", response.url(), re.I)\n    assert re.search(r\"[?&]name=test\\.jpeg\", response.url(), re.I)\n\n    # Test hosted configuration and that we can't add a valid file\n    aa = AppriseAttachment(location=ContentLocation.HOSTED)\n    # No entries defined yet\n    assert bool(aa) is False\n    assert aa.sync() is False\n\n    # Entry count does not impact sync if told to act that way\n    assert aa.sync(abort_if_empty=False) is True\n    assert aa.add(path) is False\n\n    response = AppriseAttachment.instantiate(path)\n    assert len(response) > 0\n\n    # Get file\n    assert response.download()\n\n    # Test the inability to get our file size\n    with mock.patch(\"os.path.getsize\", side_effect=(0, OSError)):\n        assert len(response) == 0\n\n    # get file again\n    assert response.download()\n    with mock.patch(\"os.path.isfile\", return_value=True):\n        response.cache = True\n        with mock.patch(\"os.path.getsize\", side_effect=OSError):\n            assert len(response) == 0\n\n\ndef test_attach_file_base64():\n    \"\"\"\n    API: AttachFile() with base64 encoding\n\n    \"\"\"\n\n    # Simple gif test\n    path = join(TEST_VAR_DIR, \"apprise-test.gif\")\n    response = AppriseAttachment.instantiate(path)\n    assert isinstance(response, AttachFile)\n    assert response.name == \"apprise-test.gif\"\n    assert response.mimetype == \"image/gif\"\n\n    # now test our base64 output\n    assert isinstance(response.base64(), str)\n    # No encoding if we choose\n    assert isinstance(response.base64(encoding=None), bytes)\n\n    # Error cases:\n    with (\n        mock.patch(\"os.path.isfile\", return_value=False),\n        pytest.raises(exception.AppriseFileNotFound),\n    ):\n        response.base64()\n\n    with mock.patch(\n        \"builtins.open\",\n        new_callable=mock.mock_open,\n        read_data=\"mocked file content\",\n    ) as mock_file:\n        mock_file.side_effect = FileNotFoundError\n        with pytest.raises(exception.AppriseFileNotFound):\n            response.base64()\n\n        mock_file.side_effect = OSError\n        with pytest.raises(exception.AppriseDiskIOError):\n            response.base64()\n"
  },
  {
    "path": "tests/test_attach_http.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport mimetypes\nfrom os.path import dirname, getsize, join\nimport re\nfrom typing import ClassVar\nfrom unittest import mock\n\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotificationManager, exception\nfrom apprise.attachment.http import AttachHTTP\nfrom apprise.common import ContentLocation\nfrom apprise.plugins import NotifyBase\n\nlogging.disable(logging.CRITICAL)\n\nTEST_VAR_DIR = join(dirname(__file__), \"var\")\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n# Some exception handling we'll use\nREQUEST_EXCEPTIONS = (\n    requests.ConnectionError(0, \"requests.ConnectionError() not handled\"),\n    requests.RequestException(0, \"requests.RequestException() not handled\"),\n    requests.HTTPError(0, \"requests.HTTPError() not handled\"),\n    requests.ReadTimeout(0, \"requests.ReadTimeout() not handled\"),\n    requests.TooManyRedirects(0, \"requests.TooManyRedirects() not handled\"),\n    # Throw OSError exceptions too\n    OSError(\"SystemError\"),\n)\n\n\ndef test_attach_http_parse_url():\n    \"\"\"\n    API: AttachHTTP().parse_url()\n\n    \"\"\"\n\n    # bad entry\n    assert AttachHTTP.parse_url(\"garbage://\") is None\n\n    # no url specified\n    assert AttachHTTP.parse_url(\"http://\") is None\n\n\ndef test_attach_http_query_string_dictionary():\n    \"\"\"\n    API: AttachHTTP() Query String Dictionary\n\n    \"\"\"\n\n    # Set verify off\n    results = AttachHTTP.parse_url(\"http://localhost?verify=no&rto=9&cto=8\")\n    assert isinstance(results, dict)\n\n    # Create our object\n    obj = AttachHTTP(**results)\n    assert isinstance(obj, AttachHTTP)\n\n    # verify is disabled and therefore set\n    assert re.search(r\"[?&]verify=no\", obj.url())\n\n    # Our connect timeout flag is set since it differs from the default\n    assert re.search(r\"[?&]cto=8\", obj.url())\n    # Our read timeout flag is set since it differs from the default\n    assert re.search(r\"[?&]rto=9\", obj.url())\n\n    # Now lets create a URL with a custom Query String entry\n\n    # some custom qsd entries specified\n    results = AttachHTTP.parse_url(\"http://localhost?dl=1&_var=test\")\n    assert isinstance(results, dict)\n\n    # Create our object\n    obj = AttachHTTP(**results)\n    assert isinstance(obj, AttachHTTP)\n\n    # verify is not in the URL as it is implied (default)\n    assert not re.search(r\"[?&]verify=yes\", obj.url())\n\n    # But now test that our custom arguments have also been set\n    assert re.search(r\"[?&]dl=1\", obj.url())\n    assert re.search(r\"[?&]_var=test\", obj.url())\n\n\n@mock.patch(\"requests.request\")\n@mock.patch(\"requests.get\")\ndef test_attach_http(mock_get, mock_request):\n    \"\"\"\n    API: AttachHTTP() object\n\n    \"\"\"\n\n    # Define our good:// url\n    class GoodNotification(NotifyBase):\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        def url(self):\n            # Support url() function\n            return \"\"\n\n    # Store our good notification in our schema map\n    N_MGR[\"good\"] = GoodNotification\n\n    # Temporary path\n    path = join(TEST_VAR_DIR, \"apprise-test.gif\")\n\n    class DummyResponse:\n        \"\"\"A dummy response used to manage our object.\"\"\"\n\n        status_code = requests.codes.ok\n        headers: ClassVar[dict[str, str]] = {\n            \"Content-Length\": getsize(path),\n            \"Content-Type\": \"text/plain\",\n        }\n\n        # Pointer to file\n        ptr = None\n\n        # used to return random keep-alive chunks\n        _keepalive_chunk_ref = 0\n\n        def close(self):\n            return\n\n        def iter_content(self, chunk_size=1024):\n            \"\"\"Lazy function (generator) to read a file piece by piece.\n\n            Default chunk size: 1k.\n            \"\"\"\n\n            while True:\n                self._keepalive_chunk_ref += 1\n                if 16 % self._keepalive_chunk_ref == 0:\n                    # Yield a keep-alive block\n                    yield \"\"\n\n                data = self.ptr.read(chunk_size)\n                if not data:\n                    break\n                yield data\n\n        def raise_for_status(self):\n            return\n\n        def __enter__(self):\n            self.ptr = open(path, \"rb\")\n            return self\n\n        def __exit__(self, *args, **kwargs):\n            self.ptr.close()\n\n    # Prepare Mock\n    dummy_response = DummyResponse()\n    mock_get.return_value = dummy_response\n\n    # Test custom url get parameters\n    results = AttachHTTP.parse_url(\n        \"http://user:pass@localhost/apprise.gif?DL=1&cache=300\"\n    )\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n\n    # Test that our extended variables are passed along\n    assert mock_get.call_count == 0\n    assert attachment\n    assert mock_get.call_count == 1\n    assert \"params\" in mock_get.call_args_list[0][1]\n    assert \"DL\" in mock_get.call_args_list[0][1][\"params\"]\n\n    # Verify that arguments that are reserved for apprise are not\n    # passed along\n    assert \"cache\" not in mock_get.call_args_list[0][1][\"params\"]\n\n    with mock.patch(\"os.unlink\", side_effect=OSError()):\n        # Test invalidation with exception thrown\n        attachment.invalidate()\n\n    results = AttachHTTP.parse_url(\n        \"http://user:pass@localhost/apprise.gif?+key=value&cache=True\"\n    )\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n    # No Content-Disposition; so we use filename from path\n    assert attachment.name == \"apprise.gif\"\n    # Format is text/plain because of the Content-Type in the HTTP Query\n    assert attachment.mimetype == \"text/plain\"\n\n    # To get our desired effect, we'd have to have had to detect everything\n    attachment.detected_mimetype = None\n    attachment._mimetype = None\n\n    # Now a call would yield a detected result that we'd agree with:\n    assert attachment.mimetype == \"image/gif\"\n\n    # had it not been there and it was forced to detect it on it's own\n    # we would have had a different result; the below forces it to detect it\n    # again:\n    attachment.detected_mimetype = None\n\n    # Now we get what we would have expected:\n    assert attachment.mimetype == \"image/gif\"\n\n    results = AttachHTTP.parse_url(\n        \"http://localhost:3000/noname.gif?name=usethis.jpg&mime=image/jpeg\"\n    )\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n    # both mime and name over-ridden\n    assert re.search(r\"[?&]mime=image/jpeg\", attachment.url())\n    assert re.search(r\"[?&]name=usethis.jpg\", attachment.url())\n    # No Content-Disposition; so we use filename from path\n    assert attachment.name == \"usethis.jpg\"\n    assert attachment.mimetype == \"image/jpeg\"\n\n    # Edge case; download called a second time when content already retrieved\n    assert attachment.download()\n    assert attachment\n    assert len(attachment) == getsize(path)\n\n    # Test case where location is simply set to INACCESSIBLE\n    # Below is a bad example, but it proves the section of code properly works.\n    # Ideally a server admin may wish to just disable all HTTP based\n    # attachments entirely. In this case, they simply just need to change the\n    # global singleton at the start of their program like:\n    #\n    # import apprise\n    # apprise.attachment.AttachHTTP.location = \\\n    #       apprise.ContentLocation.INACCESSIBLE\n    attachment = AttachHTTP(**results)\n    attachment.location = ContentLocation.INACCESSIBLE\n    assert attachment.path is None\n    # Downloads just don't work period\n    assert attachment.download() is False\n\n    # No path specified\n    # No Content-Disposition specified\n    # No filename (because no path)\n    results = AttachHTTP.parse_url(\"http://localhost\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n    # Format is text/plain because of the Content-Type in the HTTP Query\n    assert attachment.mimetype == \"text/plain\"\n\n    # To get our desired effect, we'd have to have had to detect everything\n    attachment.detected_mimetype = None\n    attachment._mimetype = None\n\n    # Now we are unable to detect our file without enough data to do it with\n    assert attachment.mimetype == \"application/octet-stream\"\n\n    # Because we could determine our mime type, we could build an extension\n    # for our unknown filename\n    assert (\n        attachment.name\n        == f\"{AttachHTTP.unknown_filename}\"\n        f\"{mimetypes.guess_extension(attachment.mimetype)}\"\n    )\n    assert attachment\n    assert len(attachment) == getsize(path)\n\n    # Set Content-Length to a value that exceeds our maximum allowable\n    dummy_response.headers[\"Content-Length\"] = AttachHTTP.max_file_size + 1\n    results = AttachHTTP.parse_url(\"http://localhost/toobig.jpg\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    # we can not download this attachment\n    assert not attachment\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n    assert attachment.mimetype is None\n    assert attachment.name is None\n    assert len(attachment) == 0\n\n    # Handle cases where we have no Content-Length and we need to rely\n    # on what is read as it is streamed\n    del dummy_response.headers[\"Content-Length\"]\n    # No path specified\n    # No Content-Disposition specified\n    # No Content-Length specified\n    # No filename (because no path)\n    results = AttachHTTP.parse_url(\"http://localhost/no-length.gif\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n\n    # Format is text/plain because of the Content-Type in the HTTP Query\n    assert attachment.mimetype == \"text/plain\"\n\n    # Because we could determine our mime type, we could build an extension\n    # for our unknown filename\n    assert attachment.name == \"no-length.gif\"\n    assert attachment\n    assert len(attachment) == getsize(path)\n\n    # Set our limit to be the length of our image; everything should work\n    # without a problem\n    max_file_size = AttachHTTP.max_file_size\n    AttachHTTP.max_file_size = getsize(path)\n    # Set ourselves a Content-Disposition (providing a filename)\n    dummy_response.headers[\"Content-Disposition\"] = (\n        'attachment; filename=\"myimage.gif\"'\n    )\n    # Remove our content type so we're forced to guess it from our filename\n    # specified in our Content-Disposition\n    del dummy_response.headers[\"Content-Type\"]\n    # No path specified\n    # No Content-Length specified\n    # Filename in Content-Disposition (over-rides one found in path\n    results = AttachHTTP.parse_url(\"http://user@localhost/ignore-filename.gif\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n    assert attachment.mimetype == \"image/gif\"\n    # Because we could determine our mime type, we could build an extension\n    # for our unknown filename\n    assert attachment.name == \"myimage.gif\"\n    assert attachment\n    assert len(attachment) == getsize(path)\n\n    # Similar to test above except we make our max message size just 1 byte\n    # smaller then our gif file. This will cause us to fail to read the\n    # attachment\n    AttachHTTP.max_file_size = getsize(path) - 1\n    results = AttachHTTP.parse_url(\"http://localhost/toobig.jpg\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    # we can not download this attachment\n    assert not attachment\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n    assert attachment.mimetype is None\n    assert attachment.name is None\n    assert len(attachment) == 0\n\n    # Disable our file size limitations\n    AttachHTTP.max_file_size = 0\n    results = AttachHTTP.parse_url(\"http://user@localhost\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n    assert attachment.mimetype == \"image/gif\"\n    # Because we could determine our mime type, we could build an extension\n    # for our unknown filename\n    assert attachment.name == \"myimage.gif\"\n    assert attachment\n    assert len(attachment) == getsize(path)\n\n    # Set our header up with an invalid Content-Length; we can still process\n    # this data. It just means we track it lower when reading back content\n    dummy_response.headers = {\"Content-Length\": \"invalid\"}\n    results = AttachHTTP.parse_url(\"http://localhost/invalid-length.gif\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n    assert attachment.mimetype == \"image/gif\"\n    # Because we could determine our mime type, we could build an extension\n    # for our unknown filename\n    assert attachment.name == \"invalid-length.gif\"\n    assert attachment\n\n    # Give ourselves nothing to work with\n    dummy_response.headers = {}\n    results = AttachHTTP.parse_url(\"http://user@localhost\")\n    assert isinstance(results, dict)\n    attachment = AttachHTTP(**results)\n    # we can not download this attachment\n    assert attachment\n    assert isinstance(attachment.url(), str) is True\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", attachment.url()) is None\n    assert re.search(r\"[?&]name=\", attachment.url()) is None\n\n    # Handle edge-case where detected_name is None for whatever reason\n    attachment.detected_name = None\n    assert attachment.mimetype == attachment.unknown_mimetype\n    assert attachment.name.startswith(AttachHTTP.unknown_filename)\n    assert len(attachment) == getsize(path)\n\n    # Exception handling\n    mock_get.return_value = None\n    for exception_ in REQUEST_EXCEPTIONS:\n        aa = AppriseAttachment.instantiate(\n            \"http://localhost/exception.gif?cache=30\"\n        )\n        assert isinstance(aa, AttachHTTP)\n\n        mock_get.side_effect = exception_\n        assert not aa\n\n    # Restore value\n    AttachHTTP.max_file_size = max_file_size\n\n    # Multi Message Testing\n    mock_get.side_effect = None\n    mock_get.return_value = DummyResponse()\n\n    # Prepare our POST response (from notify call)\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n    response.content = \"\"\n    mock_request.return_value = response\n\n    mock_get.reset_mock()\n    mock_request.reset_mock()\n    assert mock_get.call_count == 0\n\n    apobj = Apprise()\n    assert apobj.add(\"form://localhost\")\n    assert apobj.add(\"json://localhost\")\n    assert apobj.add(\"xml://localhost\")\n    assert len(apobj) == 3\n    assert (\n        apobj.notify(\n            body=\"one attachment split 3 times\",\n            attach=\"http://localhost/test.gif\",\n        )\n        is True\n    )\n\n    # We posted 3 times\n    assert mock_request.call_count == 3\n    # We only fetched once and re-used the same fetch for all posts\n    assert mock_get.call_count == 1\n\n    mock_get.reset_mock()\n    mock_request.reset_mock()\n    apobj = Apprise()\n    for n in range(10):\n        assert apobj.add(f\"json://localhost?:entry={n}&method=post\")\n        assert apobj.add(f\"form://localhost?:entry={n}&method=post\")\n        assert apobj.add(f\"xml://localhost?:entry={n}&method=post\")\n\n    assert (\n        apobj.notify(\n            body=\"one attachment split 30 times\",\n            attach=\"http://localhost/test.gif\",\n        )\n        is True\n    )\n\n    # We posted 30 times\n    assert mock_request.call_count == 30\n    # We only fetched once and re-used the same fetch for all posts\n    assert mock_get.call_count == 1\n\n    #\n    # We will test our base64 handling now\n    #\n    mock_get.reset_mock()\n    mock_request.reset_mock()\n\n    AttachHTTP.max_file_size = getsize(path)\n    # Set ourselves a Content-Disposition (providing a filename)\n    dummy_response.headers[\"Content-Disposition\"] = (\n        'attachment; filename=\"myimage.gif\"'\n    )\n    results = AttachHTTP.parse_url(\"http://user@localhost/filename.gif\")\n    assert isinstance(results, dict)\n    obj = AttachHTTP(**results)\n\n    # now test our base64 output\n    assert isinstance(obj.base64(), str)\n    # No encoding if we choose\n    assert isinstance(obj.base64(encoding=None), bytes)\n\n    # Error cases:\n    with mock.patch(\n        \"builtins.open\",\n        new_callable=mock.mock_open,\n        read_data=\"mocked file content\",\n    ) as mock_file:\n        mock_file.side_effect = FileNotFoundError\n        with pytest.raises(exception.AppriseFileNotFound):\n            obj.base64()\n\n        mock_file.side_effect = OSError\n        with pytest.raises(exception.AppriseDiskIOError):\n            obj.base64()\n"
  },
  {
    "path": "tests/test_attach_memory.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport re\nimport urllib\n\nimport pytest\n\nfrom apprise import AppriseAttachment, exception\nfrom apprise.attachment.base import AttachBase\nfrom apprise.attachment.memory import AttachMemory\nfrom apprise.common import ContentLocation\n\nlogging.disable(logging.CRITICAL)\n\n\ndef test_attach_memory_parse_url():\n    \"\"\"\n    API: AttachMemory().parse_url()\n\n    \"\"\"\n\n    # Bad Entry\n    assert AttachMemory.parse_url(object) is None\n\n    # Our filename is detected automatically\n    assert AttachMemory.parse_url(\"memory://\")\n\n    # pass our content in as a string\n    mem = AttachMemory(content=\"string\")\n    # it loads a string type by default\n    assert mem.mimetype == \"text/plain\"\n    # Our filename is automatically generated (with .txt)\n    assert re.match(r\"^[a-z0-9-]+\\.txt$\", mem.name, re.I)\n\n    # open our file\n    with mem as fp:\n        assert fp.getbuffer().nbytes == len(mem)\n\n    # pass our content in as a string\n    mem = AttachMemory(\n        content=\"<html/>\", name=\"test.html\", mimetype=\"text/html\"\n    )\n    # it loads a string type by default\n    assert mem.mimetype == \"text/html\"\n    assert mem.name == \"test.html\"\n\n    # Stub function\n    assert mem.download()\n\n    with pytest.raises(TypeError):\n        # garbage in, garbage out\n        AttachMemory(content=3)\n\n    # pointer to our data\n    pointer = mem.open()\n    assert pointer.read() == b\"<html/>\"\n\n    # pass our content in as a string\n    mem = AttachMemory(content=b\"binary-data\", name=\"raw.dat\")\n    # it loads a string type by default\n    assert mem.mimetype == \"application/octet-stream\"\n    assert mem.name == \"raw.dat\"\n\n    # pass our content in as a string\n    mem = AttachMemory(content=b\"binary-data\")\n    # it loads a string type by default\n    assert mem.mimetype == \"application/octet-stream\"\n    # Our filename is automatically generated (with .dat)\n    assert re.match(r\"^[a-z0-9-]+\\.dat$\", mem.name, re.I)\n\n\ndef test_attach_memory():\n    \"\"\"\n    API: AttachMemory()\n\n    \"\"\"\n    # A url we can test with\n    fname = \"testfile\"\n    url = f\"memory:///ignored/path/{fname}\"\n\n    # Simple gif test\n    response = AppriseAttachment.instantiate(url)\n    assert isinstance(response, AttachMemory)\n\n    # There is no path yet as we haven't written anything to our memory object\n    # yet\n    assert response.path is None\n    assert bool(response) is False\n\n    with response as memobj:\n        memobj.write(b\"content\")\n\n    # Memory object defaults\n    assert response.name == fname\n    assert response.path == response.name\n    assert response.mimetype == \"application/octet-stream\"\n    assert bool(response) is True\n\n    #\n    fname_in_url = urllib.parse.quote(response.name)\n    assert response.url().startswith(f\"memory://{fname_in_url}\")\n\n    # Mime is always part of url\n    assert re.search(r\"[?&]mime=\", response.url()) is not None\n\n    # Test case where location is simply set to INACCESSIBLE\n    # Below is a bad example, but it proves the section of code properly works.\n    # Ideally a server admin may wish to just disable all File based\n    # attachments entirely. In this case, they simply just need to change the\n    # global singleton at the start of their program like:\n    #\n    # import apprise\n    # apprise.attachment.AttachMemory.location = \\\n    #       apprise.ContentLocation.INACCESSIBLE\n    #\n    response = AppriseAttachment.instantiate(url)\n    assert isinstance(response, AttachMemory)\n    with response as memobj:\n        memobj.write(b\"content\")\n\n    response.location = ContentLocation.INACCESSIBLE\n    assert response.path is None\n    # Downloads just don't work period\n    assert response.download() is False\n\n    # File handling (even if image is set to maxium allowable)\n    response = AppriseAttachment.instantiate(url)\n    assert isinstance(response, AttachMemory)\n    with response as memobj:\n        memobj.write(b\"content\")\n\n    # Memory handling when size is to large\n    response = AppriseAttachment.instantiate(url)\n    assert isinstance(response, AttachMemory)\n    with response as memobj:\n        memobj.write(b\"content\")\n\n    # Test case where we exceed our defined max_file_size in memory\n    prev_value = AttachBase.max_file_size\n    AttachBase.max_file_size = len(response) - 1\n    # We can't work in this case\n    assert response.path is None\n    assert response.download() is False\n\n    # Restore our file_size\n    AttachBase.max_file_size = prev_value\n\n    response = AppriseAttachment.instantiate(\n        \"memory://apprise-file.gif?mime=image/gif\"\n    )\n    assert isinstance(response, AttachMemory)\n    with response as memobj:\n        memobj.write(b\"content\")\n\n    assert response.name == \"apprise-file.gif\"\n    assert response.path == response.name\n    assert response.mimetype == \"image/gif\"\n    # No mime-type and/or filename over-ride was specified, so therefore it\n    # won't show up in the generated URL\n    assert re.search(r\"[?&]mime=\", response.url()) is not None\n    assert \"image/gif\" in response.url()\n\n    # Force a mime-type and new name\n    response = AppriseAttachment.instantiate(\n        \"memory://{}?mime={}&name={}\".format(\n            \"ignored.gif\", \"image/jpeg\", \"test.jpeg\"\n        )\n    )\n    assert isinstance(response, AttachMemory)\n    with response as memobj:\n        memobj.write(b\"content\")\n\n    assert response.name == \"test.jpeg\"\n    assert response.path == response.name\n    assert response.mimetype == \"image/jpeg\"\n    # We will match on mime type now  (%2F = /)\n    assert re.search(r\"[?&]mime=image/jpeg\", response.url(), re.I)\n    assert response.url().startswith(\"memory://test.jpeg\")\n\n    # Test hosted configuration and that we can't add a valid memory file\n    aa = AppriseAttachment(location=ContentLocation.HOSTED)\n    assert aa.add(response) is False\n\n    # now test our base64 output\n    assert isinstance(response.base64(), str)\n    # No encoding if we choose\n    assert isinstance(response.base64(encoding=None), bytes)\n\n    response.invalidate()\n    with pytest.raises(exception.AppriseFileNotFound):\n        response.base64()\n"
  },
  {
    "path": "tests/test_compat_py39.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"Tests for Python 3.9 Compatibility.\"\"\"\n\nfrom __future__ import annotations\n\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise.plugins.irc import protocol\n\n\ndef test_compat_dataclass_no_exception():\n    \"\"\"dataclass_compat() py39 returns dataclass result on success.\"\"\"\n    # protocol.dataclass is a compat wrapper around apprise.compat._dataclass.\n    # Patch the underlying dataclass binding to simulate older Python\n    # behaviour where slots= is unsupported.\n    with mock.patch(\"apprise.compat._dataclass\") as m:\n        sentinel = object()\n        m.return_value = sentinel\n\n        result = protocol.dataclass(frozen=True, slots=True)\n\n        assert result is sentinel\n        m.assert_called_once_with(frozen=True, slots=True)\n\n\ndef test_compat_dataclass_strips_slots_on_typeerror():\n    \"\"\"dataclass_compat() py39 strips slots= and retries after TypeError.\"\"\"\n    with mock.patch(\"apprise.compat._dataclass\") as m:\n        sentinel = object()\n        m.side_effect = [TypeError(\"unsupported\"), sentinel]\n\n        result = protocol.dataclass(frozen=True, slots=True)\n\n        assert result is sentinel\n        assert m.call_count == 2\n\n        # First call includes slots\n        assert m.call_args_list[0].kwargs == {\"frozen\": True, \"slots\": True}\n\n        # Second call must omit slots\n        assert m.call_args_list[1].kwargs == {\"frozen\": True}\n\n\ndef test_compat_dataclass_reraises_when_no_slots():\n    \"\"\"dataclass_compat() re-raises TypeError when slots is not present.\"\"\"\n    with mock.patch(\"apprise.compat._dataclass\") as m:\n        m.side_effect = TypeError(\"boom\")\n\n        with pytest.raises(TypeError):\n            protocol.dataclass(frozen=True)\n"
  },
  {
    "path": "tests/test_config_base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timezone as _tz, tzinfo\nfrom inspect import cleandoc\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom typing import Any\nfrom unittest.mock import Mock\n\nimport pytest\nfrom pytest_mock import MockerFixture\nimport requests\nimport yaml\n\nfrom apprise import Apprise, AppriseAsset, AppriseConfig, ConfigFormat\nfrom apprise.config import ConfigBase\nfrom apprise.plugins.email import NotifyEmail\nfrom apprise.utils.time import zoneinfo\n\nlogging.disable(logging.CRITICAL)\n\n\n@pytest.fixture\ndef requests_remote_config(mocker: MockerFixture) -> Mock:\n    \"\"\"\n    Patch requests.post globally.\n\n    The config loader will still go through its normal HTTP logic, but all\n    outbound GETs will receive controlled in-memory responses.\n    \"\"\"\n\n    def fake_post(url: str, *args: Any, **kwargs: Any) -> requests.Response:\n        if url == \"http://localhost:8000/get/test-001\":\n            body = cleandoc(\"\"\"\n                json://localhost\n                form://localhost\n                \"\"\")\n        elif url == \"http://localhost:8000/get/test-002\":\n            body = cleandoc(\"\"\"\n                xml://localhost\n                \"\"\")\n        else:\n            pytest.fail(f\"Unexpected URL fetched: {url!r}\")\n\n        resp = requests.Response()\n        resp.status_code = requests.codes.ok\n        resp.url = url\n        resp._content = body.encode(\"utf-8\")  # type: ignore[attr-defined]\n        resp.encoding = \"utf-8\"\n        return resp\n\n    # Patch the actual requests.post symbol that ConfigHTTP uses internally\n    mock_post: Mock = mocker.patch(\"requests.post\", side_effect=fake_post)\n    return mock_post\n\n\ndef test_config_base():\n    \"\"\"\n    API: ConfigBase() object\n\n    \"\"\"\n\n    # invalid types throw exceptions\n    with pytest.raises(TypeError):\n        ConfigBase(**{\"format\": \"invalid\"})\n\n    # Config format types are not the same as ConfigBase ones\n    with pytest.raises(TypeError):\n        ConfigBase(**{\"format\": \"markdown\"})\n\n    cb = ConfigBase(**{\"format\": \"yaml\"})\n    assert isinstance(cb, ConfigBase)\n\n    cb = ConfigBase(**{\"format\": \"text\"})\n    assert isinstance(cb, ConfigBase)\n\n    # Set encoding\n    cb = ConfigBase(encoding=\"utf-8\", format=\"text\")\n    assert isinstance(cb, ConfigBase)\n\n    # read is not supported in the base object; only the children\n    assert cb.read() is None\n\n    # There are no servers loaded on a freshly created object\n    assert len(cb.servers()) == 0\n\n    # Unsupported URLs are not parsed\n    assert ConfigBase.parse_url(url=\"invalid://\") is None\n\n    # Valid URL & Valid Format\n    results = ConfigBase.parse_url(\n        url=\"file://relative/path?format=yaml&encoding=latin-1\"\n    )\n    assert isinstance(results, dict)\n    # These are moved into the root\n    assert results.get(\"format\") == \"yaml\"\n    assert results.get(\"encoding\") == \"latin-1\"\n\n    # But they also exist in the qsd location\n    assert isinstance(results.get(\"qsd\"), dict)\n    assert results[\"qsd\"].get(\"encoding\") == \"latin-1\"\n    assert results[\"qsd\"].get(\"format\") == \"yaml\"\n\n    # Valid URL & Invalid Format\n    results = ConfigBase.parse_url(\n        url=\"file://relative/path?format=invalid&encoding=latin-1\"\n    )\n    assert isinstance(results, dict)\n    # Only encoding is moved into the root\n    assert \"format\" not in results\n    assert results.get(\"encoding\") == \"latin-1\"\n\n    # But they will always exist in the qsd location\n    assert isinstance(results.get(\"qsd\"), dict)\n    assert results[\"qsd\"].get(\"encoding\") == \"latin-1\"\n    assert results[\"qsd\"].get(\"format\") == \"invalid\"\n\n\ndef test_config_base_detect_config_format():\n    \"\"\"\n    API: ConfigBase.detect_config_format\n\n    \"\"\"\n\n    # Garbage Handling\n    for garbage in (object(), None, 42):\n        # A response is always correctly returned\n        assert ConfigBase.detect_config_format(garbage) is None\n\n    # Empty files are valid\n    assert ConfigBase.detect_config_format(\"\") is ConfigFormat.TEXT\n\n    # Valid Text Configuration\n    assert ConfigBase.detect_config_format(\"\"\"\n    # A comment line over top of a URL\n    mailto://userb:pass@gmail.com\n    \"\"\") is ConfigFormat.TEXT\n\n    # A text file that has semi-colon as comment characters\n    # is valid too\n    assert ConfigBase.detect_config_format(\"\"\"\n    ; A comment line over top of a URL\n    mailto://userb:pass@gmail.com\n    \"\"\") is ConfigFormat.TEXT\n\n    # Valid YAML Configuration\n    assert ConfigBase.detect_config_format(\"\"\"\n    # A comment line over top of a URL\n    version: 1\n    \"\"\") is ConfigFormat.YAML\n\n    # Just a whole lot of blank lines...\n    assert ConfigBase.detect_config_format(\"\\n\\n\\n\") is ConfigFormat.TEXT\n\n    # Invalid Config\n    assert ConfigBase.detect_config_format(\"3\") is None\n\n\ndef test_config_base_config_parse():\n    \"\"\"\n    API: ConfigBase.config_parse\n\n    \"\"\"\n\n    # Garbage Handling\n    for garbage in (object(), None, 42):\n        # A response is always correctly returned\n        result = ConfigBase.config_parse(garbage)\n        # response is a tuple...\n        assert isinstance(result, tuple)\n        # containing 2 items (plugins, config)\n        assert len(result) == 2\n        # In the case of garbage in, we get garbage out; both lists are empty\n        assert result == ([], [])\n\n    # Valid Text Configuration\n    result = ConfigBase.config_parse(\n        \"\"\"\n    # A comment line over top of a URL\n    mailto://userb:pass@gmail.com\n    \"\"\",\n        asset=AppriseAsset(),\n    )\n    # We expect to parse 1 entry from the above\n    assert isinstance(result, tuple)\n    assert len(result) == 2\n    # The first element is the number of notification services processed\n    assert len(result[0]) == 1\n    # If we index into the item, we can check to see the tags associate\n    # with it\n    assert len(result[0][0].tags) == 0\n\n    # The second is the number of configuration include lines parsed\n    assert len(result[1]) == 0\n\n    # Valid Configuration\n    result = ConfigBase.config_parse(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\n#\n# Define your notification urls:\n#\nurls:\n  - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b\n  - mailto://test:password@gmail.com\n  - json://localhost:\n      - tag: devops, admin\n    \"\"\",\n        asset=AppriseAsset(),\n    )\n\n    # We expect to parse 2 entries from the above\n    assert isinstance(result, tuple)\n    assert len(result) == 2\n    assert isinstance(result[0], list)\n    assert len(result[0]) == 3\n    assert len(result[0][0].tags) == 0\n    assert len(result[0][1].tags) == 0\n    assert len(result[0][2].tags) == 2\n\n    # Test case where we pass in a bad format\n    result = ConfigBase.config_parse(\n        \"\"\"\n    ; A comment line over top of a URL\n    mailto://userb:pass@gmail.com\n    \"\"\",\n        config_format=\"invalid-format\",\n    )\n\n    # This is not parseable despite the valid text\n    assert isinstance(result, tuple)\n    assert isinstance(result[0], list)\n    assert len(result[0]) == 0\n\n    result, _ = ConfigBase.config_parse(\n        \"\"\"\n    ; A comment line over top of a URL\n    mailto://userb:pass@gmail.com\n    \"\"\",\n        config_format=ConfigFormat.TEXT,\n    )\n\n    # Parseable\n    assert isinstance(result, list)\n    assert len(result) == 1\n\n\ndef test_config_base_discord_bug_report_01():\n    \"\"\"\n    API: ConfigBase.config_parse user feedback\n\n    A Discord report that a tag was not correctly assigned to a URL when\n    presented in the following format\n       urls:\n         - json://myhost:\n           - tag: test\n             userid: test\n    \"\"\"\n    result, config = ConfigBase.config_parse(\n        \"\"\"\n    urls:\n      - json://myhost:\n        - tag: test\n          userid: test\n    \"\"\",\n        asset=AppriseAsset(),\n    )\n\n    # We expect to parse 4 entries from the above\n    assert isinstance(result, list)\n    assert isinstance(config, list)\n    assert len(result) == 1\n    assert len(result[0].tags) == 1\n    assert \"test\" in result[0].tags\n\n\ndef test_config_base_config_parse_text():\n    \"\"\"\n    API: ConfigBase.config_parse_text object\n\n    \"\"\"\n\n    # Garbage Handling\n    for garbage in (object(), None, 42):\n        # A response is always correctly returned\n        result = ConfigBase.config_parse_text(garbage)\n        # response is a tuple...\n        assert isinstance(result, tuple)\n        # containing 2 items (plugins, config)\n        assert len(result) == 2\n        # In the case of garbage in, we get garbage out; both lists are empty\n        assert result == ([], [])\n\n    # Valid Configuration\n    result, config = ConfigBase.config_parse_text(\n        \"\"\"\n    # A completely invalid token on json string (it gets ignored)\n    # but the URL is still valid\n    json://localhost?invalid-token=nodashes\n\n    # A comment line over top of a URL\n    mailto://userb:pass@gmail.com\n\n    # Test a URL using it's native format; in this case Ryver\n    https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG\n\n    # Invalid URL as it's not associated with a plugin\n    # or a native url\n    https://not.a.native.url/\n\n    # A line with mulitiple tag assignments to it\n    taga,tagb=kde://\n\n    # An include statement to Apprise API with trailing spaces:\n    include http://localhost:8080/notify/apprise\n\n    # A relative include statement (with trailing spaces)\n    include apprise.cfg     \"\"\",\n        asset=AppriseAsset(),\n    )\n\n    # We expect to parse 4 entries from the above\n    assert isinstance(result, list)\n    assert isinstance(config, list)\n    assert len(result) == 4\n    assert len(result[0].tags) == 0\n\n    # Our last element will have 2 tags associated with it\n    assert len(result[-1].tags) == 2\n    assert \"taga\" in result[-1].tags\n    assert \"tagb\" in result[-1].tags\n\n    assert len(config) == 2\n    assert \"http://localhost:8080/notify/apprise\" in config\n    assert \"apprise.cfg\" in config\n\n    # Here is a similar result set however this one has an invalid line\n    # in it which invalidates the entire file\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    # A comment line over top of a URL\n    mailto://userc:pass@gmail.com\n\n    # A line with mulitiple tag assignments to it\n    taga,tagb=windows://\n\n    I am an invalid line that does not follow any of the Apprise file rules!\n    \"\"\")\n\n    # We expect to parse 0 entries from the above because the invalid line\n    # invalidates the entire configuration file. This is for security reasons;\n    # we don't want to point at files load content in them just because they\n    # resemble an Apprise configuration.\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # More invalid data\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    # An invalid URL\n    invalid://user:pass@gmail.com\n\n    # A tag without a url\n    taga=\n\n    # A very poorly structured url\n    sns://:@/\n\n    # Just 1 token provided\n    sns://T1JJ3T3L2/\n\n    # Even with the above invalid entries, we can still\n    # have valid include lines\n    include file:///etc/apprise.cfg\n\n    # An invalid include (nothing specified afterwards)\n    include\n\n    # An include of a config type we don't support\n    include invalid://\n    \"\"\")\n\n    # We expect to parse 0 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Test case where a comment is on it's own line with nothing else\n    result, config = ConfigBase.config_parse_text(\"#\")\n    # We expect to parse 0 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Verify our tagging works when multiple tags are provided\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    tag1, tag2, tag3=json://user:pass@localhost\n    \"\"\")\n\n    assert isinstance(result, list)\n    assert len(result) == 1\n    assert len(result[0].tags) == 3\n    assert \"tag1\" in result[0].tags\n    assert \"tag2\" in result[0].tags\n    assert \"tag3\" in result[0].tags\n\n\ndef test_config_base_config_tag_groups_text():\n    \"\"\"\n    API: ConfigBase.config_tag_groups_text object\n\n    \"\"\"\n\n    # Valid Configuration\n    result, config = ConfigBase.config_parse_text(\n        \"\"\"\n    # Tag assignments\n    groupA, groupB = tagB, tagC\n\n    # groupB doubles down as it takes the entries initialized above\n    # plus the added ones defined below\n    groupB = tagA, tagB, tagD\n    groupC = groupA, groupB, groupC, tagE\n\n    # Tag that recursively looks to more tags\n    groupD = groupC\n\n    # Assigned ourselves\n    groupX = groupX\n\n    # Set up a recursive loop\n    groupE = groupF\n    groupF = groupE\n\n    # Set up a larger recursive loop\n    groupG = groupH\n    groupH = groupI\n    groupI = groupJ\n    groupJ = groupK\n    groupK = groupG\n\n    # Bad assignments\n    groupM = , , ,\n     , ,   = , , ,\n\n    # int's and floats are okay\n    1 = 2\n    a = 5\n\n    # A comment line over top of a URL\n    4, groupB = mailto://userb:pass@gmail.com\n\n    # Tag Assignments\n    tagA,groupB=json://localhost\n\n    # More Tag Assignments\n    tagC,groupB=xml://localhost\n\n    # More Tag Assignments\n    groupD=form://localhost\n\n    \"\"\",\n        asset=AppriseAsset(),\n    )\n\n    # We expect to parse 4 entries from the above\n    assert isinstance(result, list)\n    assert isinstance(config, list)\n    assert len(result) == 4\n\n    # Our first element is our group tags\n    assert len(result[0].tags) == 2\n    assert \"groupB\" in result[0].tags\n    assert \"4\" in result[0].tags\n\n    # No additional configuration is loaded\n    assert len(config) == 0\n\n    apobj = Apprise()\n    assert apobj.add(result)\n    # We match against 1 entry\n    assert len(list(apobj.find(\"tagA\"))) == 1\n    assert len(list(apobj.find(\"tagB\"))) == 0\n    assert len(list(apobj.find(\"groupA\"))) == 1\n    assert len(list(apobj.find(\"groupB\"))) == 3\n    assert len(list(apobj.find(\"groupC\"))) == 2\n    assert len(list(apobj.find(\"groupD\"))) == 3\n\n    # Invalid Assignment\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    # Must have something to equal or it's a bad line\n    group =\n\n    # A tag Assignments that is never gotten to as the line\n    # above is bad\n    groupD=form://localhost\n    \"\"\")\n\n    # We expect to parse 0 entries from the above\n    assert isinstance(result, list)\n    assert isinstance(config, list)\n    assert len(result) == 0\n    assert len(config) == 0\n\n    # Invalid Assignment\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    # Rundant assignment\n    group = group\n\n    # Our group assignment\n    group=windows://\n\n    \"\"\")\n\n    # the redundant assignment does us no harm; but it doesn't grant us any\n    # value either\n    assert isinstance(result, list)\n    assert len(result) == 1\n\n    # Our first element is our group tags\n    assert len(result[0].tags) == 1\n    assert \"group\" in result[0].tags\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # More invalid data\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    # A tag without a url or group assignment\n    taga=\n\n    \"\"\")\n\n    # We expect to parse 0 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    # A tag without a url or group assignment\n    taga= %%INVALID\n    \"\"\")\n\n    # We expect to parse 0 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n\ndef test_config_base_config_parse_text_with_url():\n    \"\"\"\n    API: ConfigBase.config_parse_text object_with_url\n\n    \"\"\"\n    # Here is a similar result set however this one has an invalid line\n    # in it which invalidates the entire file\n    result, config = ConfigBase.config_parse_text(\"\"\"\n    # Test a URL that has a URL as an argument\n    json://user:pass@localhost?+arg=http://example.com?arg2=1&arg3=3\n    \"\"\")\n\n    # No tag is parsed, but our URL successfully parses as is\n\n    assert isinstance(result, list)\n    assert len(result) == 1\n    assert len(result[0].tags) == 0\n\n    # Verify our URL is correctly captured\n    assert \"%2Barg=http%3A%2F%2Fexample.com%3Farg2%3D1\" in result[0].url()\n    assert \"json://user:pass@localhost/\" in result[0].url()\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Pass in our configuration again\n    result, config = ConfigBase.config_parse_text(result[0].url())\n\n    # Verify that our results repeat themselves\n    assert isinstance(result, list)\n    assert len(result) == 1\n    assert len(result[0].tags) == 0\n    assert \"%2Barg=http%3A%2F%2Fexample.com%3Farg2%3D1\" in result[0].url()\n    assert \"json://user:pass@localhost/\" in result[0].url()\n\n    assert len(config) == 0\n\n\ndef test_config_base_config_parse_yaml():\n    \"\"\"\n    API: ConfigBase.config_parse_yaml object\n\n    \"\"\"\n\n    # general reference used below\n    asset = AppriseAsset()\n\n    # Garbage Handling\n    for garbage in (object(), None, \"\", 42):\n        # A response is always correctly returned\n        result = ConfigBase.config_parse_yaml(garbage)\n        # response is a tuple...\n        assert isinstance(result, tuple)\n        # containing 2 items (plugins, config)\n        assert len(result) == 2\n        # In the case of garbage in, we get garbage out; both lists are empty\n        assert result == ([], [])\n\n    # Invalid Version\n    result, config = ConfigBase.config_parse_yaml(\"version: 2a\", asset=asset)\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Invalid Syntax (throws a ScannerError)\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\nurls\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Missing url token\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # No urls defined\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\nurls:\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Invalid url defined\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\n# Invalid URL definition; yet the answer to life at the same time\nurls: 43\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Invalid url/schema\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\nurls:\n  - invalid://\n\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Invalid url/schema\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\nurls:\n  - invalid://:\n    - a: b\n\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Invalid url/schema\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# Include entry with nothing associated with it\ninclude:\n\nurls:\n  - just some free text that isn't valid:\n    - a garbage entry to go with it\n\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Invalid url/schema\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\nurls:\n  - not even a proper url\n\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Invalid url/schema\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\nurls:\n  # a very invalid sns entry\n  - sns://T1JJ3T3L2/\n  - sns://:@/:\n    - invalid: test\n  - sns://T1JJ3T3L2/:\n    - invalid: test\n    - _invalid: Token can not start with an underscore\n\n  # some strangeness\n  -\n    -\n      - test\n\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Valid Configuration\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\n# Including by dict\ninclude:\n  # File includes\n  - file:///absolute/path/\n  - relative/path\n  # Trailing colon shouldn't disrupt include\n  - http://test.com:\n\n  # invalid (numeric)\n  - 4\n\n  # some strangeness\n  -\n    -\n      - test\n\n#\n# Define your notification urls:\n#\nurls:\n  - pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b\n  - mailto://test:password@gmail.com\n  - https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG\n  - https://not.a.native.url/\n\n    # A completely invalid token on json string (it gets ignored)\n    # but the URL is still valid\n  - json://localhost?invalid-token=nodashes\n\n\"\"\",\n        asset=asset,\n    )\n\n    # We expect to parse 4 entries from the above\n    # The Ryver one is in a native form and the 4th one is invalid\n    assert isinstance(result, list)\n    assert len(result) == 4\n    assert len(result[0].tags) == 0\n\n    # There were 3 include entries\n    assert len(config) == 3\n    assert \"file:///absolute/path/\" in config\n    assert \"relative/path\" in config\n    assert \"http://test.com\" in config\n\n    # Valid Configuration\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# A single line include is supported\ninclude: http://localhost:8080/notify/apprise\n\nurls:\n  # The following generates 1 service\n  - json://localhost:\n       tag: my-custom-tag, my-other-tag\n\n  # The following also generates 1 service\n  - json://localhost:\n    - tag: my-custom-tag, my-other-tag\n\n  # How to stack multiple entries (this generates 2):\n  - mailto://user:123abc@yahoo.ca:\n    - to: test@examle.com\n    - to: test2@examle.com\n\n      # This is an illegal entry; the schema can not be changed\n      schema: json\n\n  # accidently left a colon at the end of the url; no problem\n  # we'll accept it\n  - mailto://oscar:pass@gmail.com:\n\n  # A Ryver URL (using Native format); still accepted\n  - https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG:\n\n  # An invalid URL with colon (ignored)\n  - https://not.a.native.url/:\n\n  # A telegram entry (returns a None in parse_url())\n  - tgram://invalid\n\n\"\"\",\n        asset=asset,\n    )\n\n    # We expect to parse 6 entries from the above because the tgram:// entry\n    # would have failed to be loaded\n    assert isinstance(result, list)\n    assert len(result) == 6\n    assert len(result[0].tags) == 2\n\n    # Our single line included\n    assert len(config) == 1\n    assert \"http://localhost:8080/notify/apprise\" in config\n\n    # Global Tags\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# Global Tags stacked as a list\ntag:\n  - admin\n  - devops\n\nurls:\n  - json://localhost\n  - dbus://\n\"\"\",\n        asset=asset,\n    )\n\n    # We expect to parse 2 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 2\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # all entries will have our global tags defined in them\n    for entry in result:\n        assert \"admin\" in entry.tags\n        assert \"devops\" in entry.tags\n\n    # Global Tags\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# Global Tags\ntag: admin, devops\n\nurls:\n  # The following tags will get added to the global set\n  - json://localhost:\n    - tag: string-tag, my-other-tag, text\n\n  # Tags can be presented in this list format too:\n  - dbus://:\n    - tag:\n      - list-tag\n      - dbus\n\"\"\",\n        asset=asset,\n    )\n\n    # all entries will have our global tags defined in them\n    for entry in result:\n        assert \"admin\" in entry.tags\n        assert \"devops\" in entry.tags\n\n    # We expect to parse 2 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 2\n\n    # json:// has 2 globals + 3 defined\n    assert len(result[0].tags) == 5\n    assert \"text\" in result[0].tags\n\n    # json:// has 2 globals + 2 defined\n    assert len(result[1].tags) == 4\n    assert \"list-tag\" in result[1].tags\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # An invalid set of entries\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\nurls:\n  # The following tags will get added to the global set\n  - json://localhost:\n    -\n      -\n        - entry\n\"\"\",\n        asset=asset,\n    )\n\n    # We expect to parse 0 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # An asset we'll manipulate; set some system flags\n    asset = AppriseAsset(_uid=\"abc123\", _recursion=1)\n\n    # Global Tags\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# Test the creation of our apprise asset object\nasset:\n  app_id: AppriseTest\n  app_desc: Apprise Test Notifications\n  app_url: http://nuxref.com\n  async_mode: no\n\n  # System flags should never get set\n  _uid: custom_id\n  _recursion: 100\n\n  # Support setting empty values\n  image_url_mask:\n  image_url_logo:\n\n  image_path_mask: tmp/path\n\n  # Timezone (supports tz keyword too)\n  tz: America/Montreal\n\n  # invalid entry\n  theme:\n    -\n      -\n        - entry\n\n  # Now for some invalid entries\n  invalid: entry\n  __init__: can't be over-ridden\n  nolists:\n    - we don't support these entries\n    - in the apprise object\n\nurls:\n  - json://localhost:\n\"\"\",\n        asset=asset,\n    )\n\n    # We expect to parse 1 entries from the above\n    assert isinstance(result, list)\n    assert len(result) == 1\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    assert asset.app_id == \"AppriseTest\"\n    assert asset.app_desc == \"Apprise Test Notifications\"\n    assert asset.app_url == \"http://nuxref.com\"\n\n    # Verify our system flags retain only the value they were initialized to\n    assert asset._uid == \"abc123\"\n    assert asset._recursion == 1\n\n    # Boolean types stay boolean\n    assert asset.async_mode is False\n\n    # Our TimeZone\n    assert isinstance(asset.tzinfo, tzinfo)\n    assert asset.tzinfo.key == zoneinfo(\"America/Montreal\").key\n\n    # the theme was not updated and remains the same as it was\n    assert asset.theme == AppriseAsset().theme\n\n    # Empty string assignment\n    assert isinstance(asset.image_url_mask, str)\n    assert asset.image_url_mask == \"\"\n    assert isinstance(asset.image_url_logo, str)\n    assert asset.image_url_logo == \"\"\n\n    # For on-lookers looking through this file; here is a perfectly formatted\n    # YAML configuration file for your reference so you can see it without\n    # all of the errors like the ones identified above\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed. Thus this is a\n# completely optional field. It's a good idea to just add this line because it\n# will help with future ambiguity (if it ever occurs).\nversion: 1\n\n# Define an Asset object if you wish (Optional)\nasset:\n  app_id: AppriseTest\n  app_desc: Apprise Test Notifications\n  app_url: http://nuxref.com\n\n  # An invalid timezone\n  timezone: invalid\n\n# Optionally define some global tags to associate with ALL of your\n# urls below.\ntag: admin, devops\n\n# Define your URLs (Mandatory!)\nurls:\n  # Either on-line each entry like this:\n  - json://localhost\n\n  # Or add a colon to the end of the URL where you can optionally provide\n  # over-ride entries.  One of the most likely entry to be used here\n  # is the tag entry.  This gets extended to the global tag (if defined)\n  # above\n  - xml://localhost:\n    - tag: customer\n\n  # The more elements you specify under a URL the more times the URL will\n  # get replicated and used. Hence this entry actually could be considered\n  # 2 URLs being called with just the destination email address changed:\n  - mailto://george:password@gmail.com:\n     - to: jason@hotmail.com\n     - to: fred@live.com\n\n  # Again... to re-iterate, the above mailto:// would actually fire two (2)\n  # separate emails each with a different destination address specified.\n  # Be careful when defining your arguments and differentiating between\n  # when to use the dash (-) and when not to.  Each time you do, you will\n  # cause another instance to be created.\n\n  # Defining more then 1 element to a muti-set is easy, it looks like this:\n  - mailto://jackson:abc123@hotmail.com:\n     - to: jeff@gmail.com\n       tag: jeff, customer\n\n     - to: chris@yahoo.com\n       tag: chris, customer\n\"\"\",\n        asset=asset,\n    )\n\n    # okay, here is how we get our total based on the above (read top-down)\n    # +1  json:// entry\n    # +1  xml:// entry\n    # +2  mailto:// entry to jason@hotmail.com and fred@live.com\n    # +2  mailto:// entry to jeff@gmail.com and chris@yahoo.com\n    # = 6\n    assert len(result) == 6\n\n    # all six entries will have our global tags defined in them\n    for entry in result:\n        assert \"admin\" in entry.tags\n        assert \"devops\" in entry.tags\n\n    # Entries can be directly accessed as they were added\n\n    # our json:// had no additional tags added; so just the global ones\n    # So just 2; admin and devops (these were already validated above in the\n    # for loop\n    assert len(result[0].tags) == 2\n\n    # our xml:// object has 1 tag added (customer)\n    assert len(result[1].tags) == 3\n    assert \"customer\" in result[1].tags\n\n    # You get the idea, here is just a direct mapping to the remaining entries\n    # in the same order they appear above\n    assert len(result[2].tags) == 2\n    assert len(result[3].tags) == 2\n\n    assert len(result[4].tags) == 4\n    assert \"customer\" in result[4].tags\n    assert \"jeff\" in result[4].tags\n\n    assert len(result[5].tags) == 4\n    assert \"customer\" in result[5].tags\n    assert \"chris\" in result[5].tags\n\n    # There were no include entries defined\n    assert len(config) == 0\n\n    # Valid Configuration (multi inline configuration entries)\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# A configuration file that contains 2 includes separated by a comma and/or\n# space:\ninclude: http://localhost:8080/notify/apprise, http://localhost/apprise/cfg\n\n\"\"\",\n        asset=asset,\n    )\n\n    # We will have loaded no results\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # But our two configuration files will be present:\n    assert len(config) == 2\n    assert \"http://localhost:8080/notify/apprise\" in config\n    assert \"http://localhost/apprise/cfg\" in config\n\n    # Valid Configuration (another way of specifying more then one include)\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# A configuration file that contains 4 includes on their own\n# lines beneath the keyword `include`:\ninclude:\n   http://localhost:8080/notify/apprise\n   http://localhost/apprise/cfg01\n   http://localhost/apprise/cfg02\n   http://localhost/apprise/cfg03\n\n\"\"\",\n        asset=asset,\n    )\n\n    # We will have loaded no results\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # But our 4 configuration files will be present:\n    assert len(config) == 4\n    assert \"http://localhost:8080/notify/apprise\" in config\n    assert \"http://localhost/apprise/cfg01\" in config\n    assert \"http://localhost/apprise/cfg02\" in config\n    assert \"http://localhost/apprise/cfg03\" in config\n\n    # Test a configuration with an invalid schema with options\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n    urls:\n      - invalid://:\n          tag: 'invalid'\n          :name: 'Testing2'\n          :body: 'test body2'\n          :title: 'test title2'\n\"\"\",\n        asset=asset,\n    )\n\n    # We will have loaded no results\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # Valid Configuration (we allow comma separated entries for\n    # each defined bullet)\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# A configuration file that contains 4 includes on their own\n# lines beneath the keyword `include`:\ninclude:\n   - http://localhost:8080/notify/apprise, http://localhost/apprise/cfg01\n     http://localhost/apprise/cfg02\n   - http://localhost/apprise/cfg03\n\n\"\"\",\n        asset=asset,\n    )\n\n    # We will have loaded no results\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # But our 4 configuration files will be present:\n    assert len(config) == 4\n    assert \"http://localhost:8080/notify/apprise\" in config\n    assert \"http://localhost/apprise/cfg01\" in config\n    assert \"http://localhost/apprise/cfg02\" in config\n    assert \"http://localhost/apprise/cfg03\" in config\n\n\ndef test_config_base_config_parse_yaml_includes(\n    requests_remote_config: Mock,\n) -> None:\n    \"\"\"\n    API: ConfigBase.config_parse_yaml_includes\n\n    Verify that HTTP include entries are fetched via requests.get and that\n    the remote config bodies are parsed into json:// and xml:// notifiers.\n    \"\"\"\n\n    # general reference used below\n    asset = AppriseAsset()\n\n    # Initialize our apprise configuration\n    ac = AppriseConfig(asset=asset, recursion=1)\n\n    # Add our entry\n    ac.add_config(cleandoc(\"\"\"\n        # Include our Apprise Configuration from 2 locations\n        include:\n           - http://localhost:8000/get/test-001\n           - http://localhost:8000/get/test-002\n\n        # no further URLs defined\n    \"\"\"))\n\n    # Force a fresh parse and get the loaded plugin\n    servers = ac.servers()\n\n    # the following will return\n    assert len(servers) == 3\n\n    # representation for NotifyBase subclasses.\n    urls = {n.url() for n in servers}\n\n    # The *exact* URL string may include extra params depending on defaults,\n    # so we check using containment instead of strict equality.\n    assert any(u.startswith(\"json://localhost\") for u in urls)\n    assert any(u.startswith(\"xml://localhost\") for u in urls)\n    assert any(u.startswith(\"form://localhost\") for u in urls)\n\n\ndef test_yaml_vs_text_tagging():\n    \"\"\"\n    API: ConfigBase YAML vs TEXT tagging\n    \"\"\"\n\n    yaml_result, _ = ConfigBase.config_parse_yaml(\"\"\"\n    urls:\n      - mailtos://lead2gold:yesqbrulvaelyxve@gmail.com:\n         tag: mytag\n    \"\"\")\n    assert yaml_result\n\n    text_result, _ = ConfigBase.config_parse_text(\"\"\"\n    mytag=mailtos://lead2gold:yesqbrulvaelyxve@gmail.com\n    \"\"\")\n    assert text_result\n\n    # Now we compare our results and verify they are the same\n    assert len(yaml_result) == len(text_result)\n    assert isinstance(yaml_result[0], NotifyEmail)\n    assert isinstance(text_result[0], NotifyEmail)\n    assert \"mytag\" in text_result[0]\n    assert \"mytag\" in yaml_result[0]\n\n\ndef test_config_base_config_tag_groups_yaml_01():\n    \"\"\"\n    API: ConfigBase.config_tag_groups_yaml #1 object\n\n    \"\"\"\n\n    # general reference used below\n    asset = AppriseAsset()\n\n    # Valid Configuration\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\ngroups:\n  - group1: tagB, tagC, tagNotAssigned\n  - group2:\n      - tagA\n      - tagC\n  - group3:\n      - tagD: optional comment\n      - tagA: optional comment #2\n\n  # No assignment\n  - group4\n\n  # No assignment type 2\n  - group5:\n\n  # Integer assignment\n  - group6: 3\n  - group6: 3, 4, 5, test\n  - group6: 3.5, tagC\n\n  # Recursion\n  - groupA: groupB\n  - groupB: groupA\n  # And Again... (just because)\n  - groupA: groupB\n  - groupB: groupA\n\n  # Self assignment\n  - groupX: groupX\n\n  # Set up a larger recursive loop\n  - groupG: groupH\n  - groupH: groupI, groupJ\n  - groupI: groupJ, groupG\n  - groupJ: groupK, groupH, groupI\n  - groupK: groupG\n\n  # No tags assigned\n  - groupK: \",,  , ,\"\n  - \" , \": \",, , ,\"\n\n  # Multi Assignments\n  - groupL, groupM: tagD, tagA\n  - 4, groupN:\n     - tagD\n     - tagE, TagA\n\n  # Add one more tag to groupL making it different then GroupM by 1\n  - groupL: tagB\n#\n# Define your notification urls:\n#\nurls:\n  - form://localhost:\n     - tag: tagA\n  - mailto://test:password@gmail.com:\n     - tag: tagB\n  - xml://localhost:\n     - tag: tagC\n  - json://localhost:\n     - tag: tagD, tagA\n\n\"\"\",\n        asset=asset,\n    )\n\n    # We expect to parse 4 entries from the above\n    assert isinstance(result, list)\n    assert isinstance(config, list)\n    assert len(result) == 4\n\n    # Our first element is our group tags\n    assert len(result[0].tags) == 5\n    assert \"group2\" in result[0].tags\n    assert \"group3\" in result[0].tags\n    assert \"groupL\" in result[0].tags\n    assert \"groupM\" in result[0].tags\n    assert \"tagA\" in result[0].tags\n\n    # No additional configuration is loaded\n    assert len(config) == 0\n\n    apobj = Apprise()\n    assert apobj.add(result)\n    # We match against 1 entry\n    assert len(list(apobj.find(\"tagA\"))) == 2\n    assert len(list(apobj.find(\"tagB\"))) == 1\n    assert len(list(apobj.find(\"tagC\"))) == 1\n    assert len(list(apobj.find(\"tagD\"))) == 1\n    assert len(list(apobj.find(\"group1\"))) == 2\n    assert len(list(apobj.find(\"group2\"))) == 3\n    assert len(list(apobj.find(\"group3\"))) == 2\n    assert len(list(apobj.find(\"group4\"))) == 0\n    assert len(list(apobj.find(\"group5\"))) == 0\n    # json:// -- group6 -> 4 -> TagA\n    # xml://  -- group6 -> TagC\n    assert len(list(apobj.find(\"group6\"))) == 2\n    assert len(list(apobj.find(\"4\"))) == 1\n    assert len(list(apobj.find(\"groupN\"))) == 1\n\n\ndef test_config_base_config_tag_groups_yaml_02():\n    \"\"\"\n    API: ConfigBase.config_tag_groups_yaml #2 object\n\n    \"\"\"\n\n    # general reference used below\n    asset = AppriseAsset()\n\n    # Valid Configuration\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# if no version is specified then version 1 is presumed\nversion: 1\n\ngroups:\n  group1: tagB, tagC, tagNotAssigned\n  group2:\n    - tagA\n    - tagC\n  group3:\n    - tagD: optional comment\n    - tagA: optional comment #2\n\n  # No assignment type 2\n  group5:\n\n  # Integer assignment (since it's not a list, the last element prevails\n  # and replaces the above); '4' does not get appended as it would in\n  # the event this was a list instead\n  group6: 3\n  group6: 3, 4, 5, test\n  group6: 3.5, tagC\n\n  # Recursion\n  groupA: groupB\n  groupB: groupA\n  # And Again... (just because)\n  groupA: groupB\n  groupB: groupA\n\n  # Self assignment\n  groupX: groupX\n\n  # Set up a larger recursive loop\n  groupG: groupH\n  groupH: groupI, groupJ\n  groupI: groupJ, groupG\n  groupJ: groupK, groupH, groupI\n  groupK: groupG\n\n  # No tags assigned\n  groupK: \",,  , ,\"\n  \" , \": \",, , ,\"\n\n  # Multi Assignments\n  groupL, groupM: tagD, tagA\n  4, groupN:\n   - tagD\n   - tagE, TagA\n\n  # Add one more tag to groupL making it different then GroupM by 1\n  groupL: tagB\n#\n# Define your notification urls:\n#\nurls:\n  - form://localhost:\n     - tag: tagA\n  - mailto://test:password@gmail.com:\n     - tag: tagB\n  - xml://localhost:\n     - tag: tagC\n  - json://localhost:\n     - tag: tagD, tagA\n\n\"\"\",\n        asset=asset,\n    )\n\n    # We expect to parse 4 entries from the above\n    assert isinstance(result, list)\n    assert isinstance(config, list)\n    assert len(result) == 4\n\n    # Our first element is our group tags\n    assert len(result[0].tags) == 5\n    assert \"group2\" in result[0].tags\n    assert \"group3\" in result[0].tags\n    assert \"groupL\" in result[0].tags\n    assert \"groupM\" in result[0].tags\n    assert \"tagA\" in result[0].tags\n\n    # No additional configuration is loaded\n    assert len(config) == 0\n\n    apobj = Apprise()\n    assert apobj.add(result)\n    # We match against 1 entry\n    assert len(list(apobj.find(\"tagA\"))) == 2\n    assert len(list(apobj.find(\"tagB\"))) == 1\n    assert len(list(apobj.find(\"tagC\"))) == 1\n    assert len(list(apobj.find(\"tagD\"))) == 1\n    assert len(list(apobj.find(\"group1\"))) == 2\n    assert len(list(apobj.find(\"group2\"))) == 3\n    assert len(list(apobj.find(\"group3\"))) == 2\n    assert len(list(apobj.find(\"group4\"))) == 0\n    assert len(list(apobj.find(\"group5\"))) == 0\n    # NOT json:// -- group6 -> 4 -> TagA (not appended because dict storage)\n    #                          ^\n    #                          |\n    #            See: test_config_base_config_tag_groups_yaml_01 (above)\n    #                 dict storage (as this tests for) causes last entry to\n    #                 prevail; previous assignments are lost\n    #\n    # xml://  -- group6 -> TagC\n    assert len(list(apobj.find(\"group6\"))) == 1\n    assert len(list(apobj.find(\"4\"))) == 1\n    assert len(list(apobj.find(\"groupN\"))) == 1\n    assert len(list(apobj.find(\"groupK\"))) == 0\n\n\ndef test_config_base_config_parse_yaml_globals():\n    \"\"\"\n    API: ConfigBase.config_parse_yaml globals\n\n    \"\"\"\n\n    # general reference used below\n    asset = AppriseAsset()\n\n    # Invalid Syntax (throws a ScannerError)\n    results, config = ConfigBase.config_parse_yaml(\n        cleandoc(\"\"\"\n    urls:\n      - jsons://localhost1:\n         - to: jeff@gmail.com\n           tag: jeff, customer\n           cto: 30\n           rto: 30\n           verify: no\n\n      - jsons://localhost2?cto=30&rto=30&verify=no:\n         - to: json@gmail.com\n           tag: json, customer\n    \"\"\"),\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(results, list)\n\n    # Our results loaded\n    assert len(results) == 2\n    assert len(config) == 0\n\n    # Now verify that our global variables correctly initialized\n    for entry in results:\n        assert entry.verify_certificate is False\n        assert entry.socket_read_timeout == 30\n        assert entry.socket_connect_timeout == 30\n\n\n# This test fails on CentOS 8.x so it was moved into it's own function\n# so it could be bypassed. The ability to use lists in YAML files didn't\n# appear to happen until later on; it's certainly not available in v3.12\n# which was what shipped with CentOS v8 at the time.\n@pytest.mark.skipif(\n    int(yaml.__version__.split(\".\")[0]) <= 3,\n    reason=\"requires pyaml v4.x or higher.\",\n)\ndef test_config_base_config_parse_yaml_list():\n    \"\"\"\n    API: ConfigBase.config_parse_yaml list parsing\n\n    \"\"\"\n\n    # general reference used below\n    asset = AppriseAsset()\n\n    # Invalid url/schema\n    result, config = ConfigBase.config_parse_yaml(\n        \"\"\"\n# no lists... just no\nurls: [milk, pumpkin pie, eggs, juice]\n\n# Including by list is okay\ninclude: [file:///absolute/path/, relative/path, http://test.com]\n\n\"\"\",\n        asset=asset,\n    )\n\n    # Invalid data gets us an empty result set\n    assert isinstance(result, list)\n    assert len(result) == 0\n\n    # There were 3 include entries\n    assert len(config) == 3\n    assert \"file:///absolute/path/\" in config\n    assert \"relative/path\" in config\n    assert \"http://test.com\" in config\n\n\ndef test_yaml_asset_timezone_and_asset_tokens(tmpdir):\n    \"\"\"\n    Covers: valid tz, reserved keys, invalid key, bool coercion, None->\"\",\n    invalid type for string, and %z formatting path used later by plugins.\n    \"\"\"\n    cfg = tmpdir.join(\"asset-tz.yml\")\n    cfg.write(\n        \"\"\"\nversion: 1\nasset:\n  tz: \"  america/toronto  \"     # case-insensitive + whitespace cleanup\n  _private: \"ignored\"           # reserved (starts with _)\n  name_: \"ignored\"              # reserved (ends with _)\n  not_a_field: \"ignored\"        # invalid asset key\n  secure_logging: \"yes\"         # string -> bool via parse_bool\n  app_id: null                  # None becomes empty string\n  app_desc: [ \"list\" ]          # invalid type for string -> warning path\nurls:\n  - json://localhost\n\"\"\"\n    )\n\n    ac = AppriseConfig(paths=str(cfg))\n    # Force a fresh parse and get the loaded plugin\n    servers = ac.servers()\n    assert len(servers) == 1\n\n    plugin = servers[0]\n    asset = plugin.asset\n\n    # tz was accepted and normalised\n    # lower() is required since Mac and Window are not case sensitive and will\n    # See output as it was passed in and not corrected per IANA\n    assert getattr(asset.tzinfo, \"key\", None).lower() == \"america/toronto\"\n    # boolean coercion applied\n    assert asset.secure_logging is True\n    # None -> \"\"\n    assert asset.app_id == \"\"\n\n\ndef test_yaml_asset_timezone_invalid_and_precedence(tmpdir):\n    \"\"\"\n    If 'timezone' is present but invalid, it takes precedence over 'tz'\n    and MUST NOT set the asset to the 'tz' value. We assert that London\n    was not applied. We deliberately avoid asserting the exact fallback,\n    since environments may surface a system tz (datetime.timezone) that\n    lacks a `.key` attribute.\n    \"\"\"\n    cfg = tmpdir.join(\"asset-tz-invalid.yml\")\n    cfg.write(\n        \"\"\"\nversion: 1\nasset:\n  timezone: null                # invalid (will be seen as \"None\")\n  tz: Europe/London             # would be valid, but 'timezone' wins\nurls:\n  - json://localhost\n\"\"\"\n    )\n\n    base_asset = AppriseAsset(timezone=\"UTC\")\n    ac = AppriseConfig(paths=str(cfg))\n    servers = ac.servers(asset=base_asset)\n    assert len(servers) == 1\n\n    tzinfo = servers[0].asset.tzinfo\n\n    # The key assertion: 'tz' MUST NOT have been applied\n    assert getattr(tzinfo, \"key\", \"\").lower() != \"europe/london\"\n\n    # Sanity check that something sensible is set\n    # Compare offsets at a fixed instant instead of object identity\n    dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc)\n    assert tzinfo.utcoffset(dt) is not None\n\n\n@pytest.mark.parametrize(\"garbage_yaml\", [\n    \"123\", \"3.1415\", \"true\", \"[UTC]\", \"{x: UTC}\",\n])\ndef test_yaml_asset_tz_garbage_types_only(tmpdir, garbage_yaml):\n    \"\"\"\n    If only 'tz' is present and it is non-string, it is ignored.\n    We assert it didn't become a real IANA zone (e.g., Europe/London),\n    and that the tzinfo is usable.\n    \"\"\"\n    cfg = tmpdir.join(\"asset-tz-garbage-only.yml\")\n    cfg.write(\n        f\"\"\"\nversion: 1\nasset:\n  tz: {garbage_yaml}            # non-string -> warning path\nurls:\n  - json://localhost\n\"\"\"\n    )\n\n    base_asset = AppriseAsset(timezone=\"UTC\")\n    ac = AppriseConfig(paths=str(cfg))\n    servers = ac.servers(asset=base_asset)\n    assert len(servers) == 1\n\n    tzinfo = servers[0].asset.tzinfo\n\n    # 1) Did not “accidentally” become a valid IANA from elsewhere.\n    assert getattr(tzinfo, \"key\", \"\").lower() != \"europe/london\"\n\n    # 2) tzinfo is usable (offset resolves at a fixed instant).\n    dt = datetime(2024, 1, 1, 12, 0, tzinfo=_tz.utc)\n    assert tzinfo.utcoffset(dt) is not None\n    # also stable tzname resolution\n    assert isinstance(tzinfo.tzname(dt), str)\n\n\ndef test_config_base_parse_yaml_file05_tags_alias_dict_form(tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_yaml_file (#5)\n\n    Validate `tags` is accepted as an alias of `tag` in the dict form:\n      - \"schema://...\":\n           tags: a-tag\n\n    Also implicitly verifies `tags` does not leak into plugin kwargs, because\n    plugin instantiation would fail if an unexpected kwarg is passed through.\n    \"\"\"\n    t = tmpdir.mkdir(\"tags-alias-dict-form\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:\n      tags: test1\n  - pover://rg8ta87qngcrkc6t4qbykxktou0uug@tqs3i88xlufexwl8t4asglt4zp5wfn:\n      tags: test2\n  - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb:\n      tags: test3\n\"\"\")\n\n    ac = AppriseConfig(paths=str(t))\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # All entries should load\n    assert len(ac.servers()) == 3\n\n    a = Apprise()\n    assert a.add(servers=ac) is True\n    assert len(a) == 3\n\n    # Verify tag matching works\n    assert sum(1 for _ in a.find(\"no-match\")) == 0\n    assert sum(1 for _ in a.find(\"all\")) == 3\n    assert sum(1 for _ in a.find(\"test1\")) == 1\n    assert sum(1 for _ in a.find(\"test2\")) == 1\n    assert sum(1 for _ in a.find(\"test3\")) == 1\n    assert sum(1 for _ in a.find(\"test1, test3\")) == 2\n\n\ndef test_config_base_parse_yaml_file06_tags_alias_list_form(tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_yaml_file (#6)\n\n    Validate `tags` is accepted as an alias of `tag` in the list-of-dicts form:\n      - \"schema://...\":\n          - tags: a-tag\n          - tags: another-tag\n\n    Expected behaviour: expands into multiple entries (one per list item),\n    and each is tagged appropriately.\n    \"\"\"\n    t = tmpdir.mkdir(\"tags-alias-list-form\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:\n    - tags: test1\n    - tags: test2\n  - pover://jcqgnlyq2oetea4qg3iunahj8d5ijm@evalvutkhc8ipmz2lcgc70wtsm0qpb:\n    - tags: test3\n\"\"\")\n\n    ac = AppriseConfig(paths=str(t))\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # First URL expands to 2, second expands to 1\n    assert len(ac.servers()) == 3\n\n    a = Apprise()\n    assert a.add(servers=ac) is True\n    assert len(a) == 3\n\n    # Verify tag matching works across expanded entries\n    assert sum(1 for _ in a.find(\"test1\")) == 1\n    assert sum(1 for _ in a.find(\"test2\")) == 1\n    assert sum(1 for _ in a.find(\"test3\")) == 1\n    assert sum(1 for _ in a.find(\"test1, test2\")) == 2\n\n\ndef test_config_base_parse_yaml_file07_tag_priority_over_tags(tmpdir):\n    \"\"\"\n    API: ConfigBase.parse_yaml_file (#7)\n\n    Validate priority: when both `tag` and `tags` are present, `tag` wins.\n\n    This must remain true to preserve the original documented behaviour.\n    \"\"\"\n    t = tmpdir.mkdir(\"tag-priority-over-tags\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n  - pover://nsisxnvnqixq39t0cw54pxieyvtdd9@2jevtmstfg5a7hfxndiybasttxxfku:\n      tag: primary\n      tags: secondary\n\"\"\")\n\n    ac = AppriseConfig(paths=str(t))\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # Entry should load successfully\n    assert len(ac.servers()) == 1\n\n    a = Apprise()\n    assert a.add(servers=ac) is True\n    assert len(a) == 1\n\n    # Tag priority check\n    assert sum(1 for _ in a.find(\"primary\")) == 1\n    assert sum(1 for _ in a.find(\"secondary\")) == 0\n"
  },
  {
    "path": "tests/test_config_file.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom apprise import AppriseAsset\nfrom apprise.config.file import ConfigFile\nfrom apprise.plugins import NotifyBase\n\nlogging.disable(logging.CRITICAL)\n\n\ndef test_config_file(tmpdir):\n    \"\"\"\n    API: ConfigFile() object\n\n    \"\"\"\n\n    assert ConfigFile.parse_url(\"garbage://\") is None\n\n    # Test cases where our URL is invalid\n    t = tmpdir.mkdir(\"testing\").join(\"apprise\")\n    t.write(\"gnome://\")\n\n    assert ConfigFile.parse_url(\"file://?\") is None\n\n    # Create an Apprise asset we can reference\n    asset = AppriseAsset()\n\n    # Initialize our object\n    cf = ConfigFile(path=str(t), format=\"text\", asset=asset)\n\n    # one entry added\n    assert len(cf) == 1\n\n    assert isinstance(cf.url(), str) is True\n\n    # Verify that we're using the same asset\n    assert cf[0].asset is asset\n\n    # Testing of pop\n    cf = ConfigFile(path=str(t), format=\"text\")\n\n    ref = cf[0]\n    assert isinstance(ref, NotifyBase) is True\n\n    ref_popped = cf.pop(0)\n    assert isinstance(ref_popped, NotifyBase) is True\n\n    assert ref == ref_popped\n\n    assert len(cf) == 0\n\n    # reference to calls on initial reference\n    cf = ConfigFile(path=str(t), format=\"text\")\n    assert isinstance(cf.pop(0), NotifyBase) is True\n\n    cf = ConfigFile(path=str(t), format=\"text\")\n    assert isinstance(cf[0], NotifyBase) is True\n    # Second reference actually uses cache\n    assert isinstance(cf[0], NotifyBase) is True\n\n    cf = ConfigFile(path=str(t), format=\"text\")\n    # Itereator creation (nothing needed to assert here)\n    iter(cf)\n    # Second reference actually uses cache\n    iter(cf)\n\n    # Cache Handling; cache each request for 30 seconds\n    results = ConfigFile.parse_url(f\"file://{t!s}?cache=30\")\n    assert isinstance(results, dict)\n    cf = ConfigFile(**results)\n    assert isinstance(cf.url(), str) is True\n    assert isinstance(cf.read(), str) is True\n\n\ndef test_config_file_exceptions(tmpdir):\n    \"\"\"\n    API: ConfigFile() i/o exception handling\n\n    \"\"\"\n\n    # Test cases where our URL is invalid\n    t = tmpdir.mkdir(\"testing\").join(\"apprise\")\n    t.write(\"gnome://\")\n\n    # Initialize our object\n    cf = ConfigFile(path=str(t), format=\"text\")\n\n    # Internal Exception would have been thrown and this would fail\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        assert cf.read() is None\n\n    # handle case where the file is to large for what was expected:\n    max_buffer_size = cf.max_buffer_size\n    cf.max_buffer_size = 1\n    assert cf.read() is None\n\n    # Restore default value\n    cf.max_buffer_size = max_buffer_size\n"
  },
  {
    "path": "tests/test_config_http.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport time\nfrom typing import ClassVar\nfrom unittest import mock\n\nimport pytest\nimport requests\n\nfrom apprise import NotificationManager\nfrom apprise.common import ConfigFormat\nfrom apprise.config.http import ConfigHTTP\nfrom apprise.plugins import NotifyBase\n\nlogging.disable(logging.CRITICAL)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n# Some exception handling we'll use\nREQUEST_EXCEPTIONS = (\n    requests.ConnectionError(0, \"requests.ConnectionError() not handled\"),\n    requests.RequestException(0, \"requests.RequestException() not handled\"),\n    requests.HTTPError(0, \"requests.HTTPError() not handled\"),\n    requests.ReadTimeout(0, \"requests.ReadTimeout() not handled\"),\n    requests.TooManyRedirects(0, \"requests.TooManyRedirects() not handled\"),\n)\n\n\n@mock.patch(\"requests.post\")\ndef test_config_http(mock_post):\n    \"\"\"\n    API: ConfigHTTP() object\n\n    \"\"\"\n\n    # Define our good:// url\n    class GoodNotification(NotifyBase):\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        def url(self, **kwargs):\n            # Support url() function\n            return \"\"\n\n    # Store our good notification in our schema map\n    N_MGR[\"good\"] = GoodNotification\n\n    # Our default content\n    default_content = \"\"\"taga,tagb=good://server01\"\"\"\n\n    class DummyResponse:\n        \"\"\"A dummy response used to manage our object.\"\"\"\n\n        status_code = requests.codes.ok\n        headers: ClassVar[dict[str, str]] = {\n            \"Content-Length\": str(len(default_content)),\n            \"Content-Type\": \"text/plain\",\n        }\n\n        text = default_content\n\n        # Pointer to file\n        ptr = None\n\n        def close(self):\n            return\n\n        def raise_for_status(self):\n            return\n\n        def __enter__(self):\n            return self\n\n        def __exit__(self, *args, **kwargs):\n            return\n\n    # Prepare Mock\n    dummy_response = DummyResponse()\n    mock_post.return_value = dummy_response\n\n    assert ConfigHTTP.parse_url(\"garbage://\") is None\n\n    results = ConfigHTTP.parse_url(\"http://user:pass@localhost?+key=value\")\n    assert isinstance(results, dict)\n    ch = ConfigHTTP(**results)\n    assert isinstance(ch.url(), str) is True\n    assert isinstance(ch.read(), str) is True\n\n    # one entry added\n    assert len(ch) == 1\n\n    results = ConfigHTTP.parse_url(\"http://localhost:8080/path/\")\n    assert isinstance(results, dict)\n    ch = ConfigHTTP(**results)\n    assert isinstance(ch.url(), str) is True\n    assert isinstance(ch.read(), str) is True\n\n    # one entry added\n    assert len(ch) == 1\n\n    # Clear all our mock counters\n    mock_post.reset_mock()\n\n    # Cache Handling; cache each request for 30 seconds\n    results = ConfigHTTP.parse_url(\"http://localhost:8080/path/?cache=30\")\n    assert mock_post.call_count == 0\n    assert isinstance(ch.url(), str) is True\n\n    assert isinstance(results, dict)\n    ch = ConfigHTTP(**results)\n    assert mock_post.call_count == 0\n\n    assert isinstance(ch.url(), str) is True\n    assert mock_post.call_count == 0\n\n    assert isinstance(ch.read(), str) is True\n    assert mock_post.call_count == 1\n\n    # Clear all our mock counters\n    mock_post.reset_mock()\n\n    # Behind the scenes we haven't actually made a fetch yet. We can consider\n    # our content expired at this point\n    assert ch.expired() is True\n\n    # Test using boolean check; this will force a remote fetch\n    assert ch\n\n    # Now a call was made\n    assert mock_post.call_count == 1\n    mock_post.reset_mock()\n\n    # Our content hasn't expired yet (it's good for 30 seconds)\n    assert ch.expired() is False\n    assert len(ch) == 1\n    assert mock_post.call_count == 0\n\n    # Test using boolean check; we will re-use our cache and not\n    # make another remote request\n    mock_post.reset_mock()\n    assert ch\n    assert len(ch.servers()) == 1\n    assert len(ch) == 1\n\n    # No remote post has been made\n    assert mock_post.call_count == 0\n\n    with mock.patch(\"time.time\", return_value=time.time() + 10):\n        # even with 10 seconds elapsed, no fetch will be made\n        assert ch.expired() is False\n        assert ch\n        assert len(ch.servers()) == 1\n        assert len(ch) == 1\n\n    # No remote post has been made\n    assert mock_post.call_count == 0\n\n    with mock.patch(\"time.time\", return_value=time.time() + 31):\n        # but 30+ seconds from now is considered expired\n        assert ch.expired() is True\n        assert ch\n        assert len(ch.servers()) == 1\n        assert len(ch) == 1\n\n    # Our content would have been renewed with a single new fetch\n    assert mock_post.call_count == 1\n\n    # one entry added\n    assert len(ch) == 1\n\n    # Invalid cache\n    results = ConfigHTTP.parse_url(\"http://localhost:8080/path/?cache=False\")\n    assert isinstance(results, dict)\n    assert isinstance(ch.url(), str) is True\n\n    results = ConfigHTTP.parse_url(\"http://localhost:8080/path/?cache=-10\")\n    assert isinstance(results, dict)\n    with pytest.raises(TypeError):\n        ch = ConfigHTTP(**results)\n\n    results = ConfigHTTP.parse_url(\"http://user@localhost?format=text\")\n    assert isinstance(results, dict)\n    ch = ConfigHTTP(**results)\n    assert isinstance(ch.url(), str) is True\n    assert isinstance(ch.read(), str) is True\n\n    # one entry added\n    assert len(ch) == 1\n\n    results = ConfigHTTP.parse_url(\"https://localhost\")\n    assert isinstance(results, dict)\n    ch = ConfigHTTP(**results)\n    assert isinstance(ch.url(), str) is True\n    assert isinstance(ch.read(), str) is True\n\n    # one entry added\n    assert len(ch) == 1\n\n    # Testing of pop\n    ch = ConfigHTTP(**results)\n\n    ref = ch[0]\n    assert isinstance(ref, NotifyBase) is True\n\n    ref_popped = ch.pop(0)\n    assert isinstance(ref_popped, NotifyBase) is True\n\n    assert ref == ref_popped\n\n    assert len(ch) == 0\n\n    # reference to calls on initial reference\n    ch = ConfigHTTP(**results)\n    assert isinstance(ch.pop(0), NotifyBase) is True\n\n    ch = ConfigHTTP(**results)\n    assert isinstance(ch[0], NotifyBase) is True\n    # Second reference actually uses cache\n    assert isinstance(ch[0], NotifyBase) is True\n\n    ch = ConfigHTTP(**results)\n    # Itereator creation (nothing needed to assert here)\n    iter(ch)\n    # Second reference actually uses cache\n    iter(ch)\n\n    # Test a buffer size limit reach\n    ch.max_buffer_size = len(dummy_response.text)\n    assert isinstance(ch.read(), str) is True\n\n    # Test YAML detection\n    yaml_supported_types = (\n        \"text/yaml\",\n        \"text/x-yaml\",\n        \"application/yaml\",\n        \"application/x-yaml\",\n    )\n\n    for st in yaml_supported_types:\n        dummy_response.headers[\"Content-Type\"] = st\n        ch.default_config_format = None\n        assert isinstance(ch.read(), str) is True\n        # Set to YAML\n        assert ch.default_config_format == ConfigFormat.YAML\n\n    # Test TEXT detection\n    text_supported_types = (\"text/plain\", \"text/html\")\n\n    for st in text_supported_types:\n        dummy_response.headers[\"Content-Type\"] = st\n        ch.default_config_format = None\n        assert isinstance(ch.read(), str) is True\n        # Set to TEXT\n        assert ch.default_config_format == ConfigFormat.TEXT\n\n    # The type is never adjusted to mime types we don't understand\n    ukwn_supported_types = (\"text/css\", \"application/zip\")\n\n    for st in ukwn_supported_types:\n        dummy_response.headers[\"Content-Type\"] = st\n        ch.default_config_format = None\n        assert isinstance(ch.read(), str) is True\n        # Remains unchanged\n        assert ch.default_config_format is None\n\n    # When the entry is missing; we handle this too\n    del dummy_response.headers[\"Content-Type\"]\n    ch.default_config_format = None\n    assert isinstance(ch.read(), str) is True\n    # Remains unchanged\n    assert ch.default_config_format is None\n\n    # Restore our content type object for lower tests\n    dummy_response.headers[\"Content-Type\"] = \"text/plain\"\n\n    # Take a snapshot\n    max_buffer_size = ch.max_buffer_size\n\n    ch.max_buffer_size = len(dummy_response.text) - 1\n    assert ch.read() is None\n\n    # Restore buffer size count\n    ch.max_buffer_size = max_buffer_size\n\n    # Test erroneous Content-Length\n    # Our content is still within the limits, so we're okay\n    dummy_response.headers[\"Content-Length\"] = \"garbage\"\n\n    assert isinstance(ch.read(), str) is True\n\n    dummy_response.headers[\"Content-Length\"] = \"None\"\n    # Our content is still within the limits, so we're okay\n    assert isinstance(ch.read(), str) is True\n\n    # Handle cases where the content length is exactly at our limit\n    dummy_response.text = \"a\" * ch.max_buffer_size\n    # This is acceptable\n    assert isinstance(ch.read(), str) is True\n\n    # If we are over our limit though..\n    dummy_response.text = \"b\" * (ch.max_buffer_size + 1)\n    assert ch.read() is None\n\n    # Test an invalid return code\n    dummy_response.status_code = 400\n    assert ch.read() is None\n    ch.max_error_buffer_size = 0\n    assert ch.read() is None\n\n    # Exception handling\n    for exception in REQUEST_EXCEPTIONS:\n        mock_post.side_effect = exception\n        assert ch.read() is None\n\n    # Restore buffer size count\n    ch.max_buffer_size = max_buffer_size\n"
  },
  {
    "path": "tests/test_config_memory.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom apprise.config.memory import ConfigMemory\n\nlogging.disable(logging.CRITICAL)\n\n\ndef test_config_memory():\n    \"\"\"\n    API: ConfigMemory() object\n\n    \"\"\"\n\n    assert ConfigMemory.parse_url(\"garbage://\") is None\n\n    # Initialize our object\n    cm = ConfigMemory(content=\"json://localhost\", format=\"text\")\n\n    # one entry added\n    assert len(cm) == 1\n\n    # Test general functions\n    assert isinstance(cm.url(), str)\n    assert isinstance(cm.read(), str)\n\n    # Test situation where an auto-detect is required:\n    cm = ConfigMemory(content=\"json://localhost\")\n\n    # one entry added\n    assert len(cm) == 1\n\n    # Test general functions\n    assert isinstance(cm.url(), str)\n    assert isinstance(cm.read(), str)\n\n    # Test situation where we can not detect the data\n    assert len(ConfigMemory(content=\"garbage\")) == 0\n"
  },
  {
    "path": "tests/test_conversion.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\n\n# Disable logging for a cleaner testing output\nimport logging\n\nimport pytest\n\nfrom apprise import NotifyFormat\nfrom apprise.conversion import convert_between\n\nlogging.disable(logging.CRITICAL)\n\n\ndef test_conversion_html_to_text():\n    \"\"\"conversion: Test HTML to plain text\"\"\"\n\n    def to_html(body):\n        \"\"\"A function to simply html conversion tests.\"\"\"\n        return convert_between(NotifyFormat.HTML, NotifyFormat.TEXT, body)\n\n    assert to_html(\"No HTML code here.\") == \"No HTML code here.\"\n\n    clist = to_html(\"<ul><li>Lots and lots</li><li>of lists.</li></ul>\")\n    assert \"Lots and lots\" in clist\n    assert \"of lists.\" in clist\n\n    assert \"To be or not to be.\" in to_html(\n        \"<blockquote>To be or not to be.</blockquote>\"\n    )\n\n    cspace = to_html(\n        \"<h2>Fancy heading</h2><p>And a paragraph too.<br>Plus line break.</p>\"\n    )\n    assert \"Fancy heading\" in cspace\n    assert \"And a paragraph too.\\nPlus line break.\" in cspace\n\n    assert (\n        to_html(\n            \"<style>body { font: 200%; }</style>\"\n            \"<p>Some obnoxious text here.</p>\"\n        )\n        == \"Some obnoxious text here.\"\n    )\n\n    assert (\n        to_html(\"<p>line 1</p><p>line 2</p><p>line 3</p>\")\n        == \"line 1\\nline 2\\nline 3\"\n    )\n\n    # Case sensitivity\n    assert (\n        to_html(\"<p>line 1</P><P>line 2</P><P>line 3</P>\")\n        == \"line 1\\nline 2\\nline 3\"\n    )\n\n    # double new lines (testing <br> and </br>)\n    assert (\n        to_html(\"some information<br/><br>and more information\")\n        == \"some information\\n\\nand more information\"\n    )\n\n    #\n    # Test bad tags\n    #\n\n    # first 2 entries are okay, but last will do as best as it can\n    assert (\n        to_html(\"<p>line 1</><p>line 2</gar><p>line 3>\")\n        == \"line 1\\nline 2\\nline 3>\"\n    )\n\n    # Make sure we ignore fields that aren't important to us\n    assert (\n        to_html(\n            \"<script>ignore this</script>\"\n            \"<p>line 1</p>\"\n            \"Another line without being enclosed\"\n        )\n        == \"line 1\\nAnother line without being enclosed\"\n    )\n\n    # Test cases when there are no new lines (we're dealing with just inline\n    # entries); an empty entry as well\n    assert (\n        to_html(\"<span></span<<span>test</span> <a href='#'>my link</a>\")\n        == \"test my link\"\n    )\n\n    # </p> missing\n    assert (\n        to_html(\n            \"<body><div>line 1 <b>bold</b></div>  \"\n            \" <a href='#'>my link</a>\"\n            \"<p>3rd line</body>\"\n        )\n        == \"line 1 bold\\nmy link\\n3rd line\"\n    )\n\n    # <hr/> on it's own\n    assert to_html(\"<hr/>\") == \"---\"\n    assert to_html(\"<hr>\") == \"---\"\n\n    # We need to handle HTML Encodings\n    assert to_html(\"\"\"\n        <html>\n            <title>ignore this entry</title>\n        <body>\n          Let&apos;s handle&nbsp;special html encoding\n          <hr/>\n        </body>\n        \"\"\") == \"Let's handle special html encoding\\n---\"\n\n    # If you give nothing, you get nothing in return\n    assert to_html(\"\") == \"\"\n\n    # Special case on HR tag\n    assert (\n        to_html(\"\"\"\n        <html>\n            <head></head>\n            <body>\n                <p><b>FROM: </b>apprise-test@mydomain.yyy\n                <apprise-test@mydomain.yyy></p>\n                Hi!<br/>\n                How are you?<br/>\n<font color=3D\"#FF0000\">red font</font>\n<a href=3D\"http://www.python.org\">link</a> you wanted.<br/>\n            </body>\n        </html>\n        \"\"\")\n        == \"FROM: apprise-test@mydomain.yyy\\nHi!\\n How are you?\\n red font\"\n        \" link you wanted.\"\n    )\n\n    assert (\n        to_html(\"\"\"\n        <html>\n            <head></head>\n            <body>\n                <p><b>FROM: </b>apprise-test@mydomain.yyy\n                    <apprise-test@mydomain.yyy><hr></p>\n                Hi!<br/>\n                How are you?<br/>\n<font color=3D\"#FF0000\">red font</font>\n<a href=3D\"http://www.python.org\">link</a> you wanted.<br/>\n            </body>\n        </html>\n        \"\"\")\n        == \"FROM: apprise-test@mydomain.yyy\\n---\\nHi!\\n How are you?\\n red\"\n        \" font link you wanted.\"\n    )\n\n    # Special case on HR if text is sorrunded by HR tags\n    # its created a dict element\n    assert (\n        to_html(\"\"\"\n        <html>\n            <head></head>\n            <body>\n                <p><hr><b>FROM: </b>apprise-test@mydomain.yyy\n                    <apprise-test@mydomain.yyy><hr></p>\n                Hi!<br/>\n                How are you?<br/>\n<font color=3D\"#FF0000\">red font</font>\n<a href=3D\"http://www.python.org\">link</a> you wanted.<br/>\n            </body>\n        </html>\n        \"\"\")\n        == \"---\\nFROM: apprise-test@mydomain.yyy\\n---\\nHi!\\n How are you?\\n\"\n        \" red font link you wanted.\"\n    )\n\n    assert (\n        to_html(\"\"\"\n        <html>\n            <head></head>\n            <body>\n                <p>\n                    <hr><b>TEST</b><hr>\n                </p>\n                Hi!<br/>\n                How are you?<br/>\n<font color=3D\"#FF0000\">red font</font>\n<a href=3D\"http://www.python.org\">link</a> you wanted.<br/>\n            </body>\n            </html>\n        \"\"\")\n        == \"---\\nTEST\\n---\\nHi!\\n How are you?\\n red font link you wanted.\"\n    )\n\n    with pytest.raises(TypeError):\n        # Invalid input\n        assert to_html(None)\n\n    with pytest.raises(TypeError):\n        # Invalid input\n        assert to_html(42)\n\n    with pytest.raises(TypeError):\n        # Invalid input\n        assert to_html(object)\n\n\ndef test_conversion_text_to():\n    \"\"\"conversion: Test Text to all types\"\"\"\n\n    response = convert_between(\n        NotifyFormat.TEXT,\n        NotifyFormat.HTML,\n        \"<title>Test Message</title><body>Body</body>\",\n    )\n\n    assert (\n        response\n        == \"&lt;title&gt;Test&nbsp;Message&lt;/title&gt;&lt;body&gt;Body&lt;\"\n        \"/body&gt;\"\n    )\n\n\ndef test_conversion_markdown_to_html():\n    \"\"\"conversion: Test markdown to html\"\"\"\n\n    # While this uses the underlining markdown library\n    # what we're testing for are the edge cases we know it doesn't support\n    # hence, `-` (a dash) with the markdown library must be a `*` to work\n    # correctly\n    response = convert_between(\n        NotifyFormat.MARKDOWN,\n        NotifyFormat.HTML,\n        cleandoc(\"\"\"\n        ## Some Heading\n\n        With Data:\n\n        - Foo\n        - Bar\n        \"\"\"),\n    )\n\n    assert \"<li>Foo</li>\" in response\n    assert \"<li>Bar</li>\" in response\n    assert \"<h2>Some Heading</h2>\" in response\n    assert \"<br />\" not in response\n\n    # if the - follows With Data on the very next line, it's consider to not\n    # requiring indentation\n    response = convert_between(\n        NotifyFormat.MARKDOWN,\n        NotifyFormat.HTML,\n        cleandoc(\"\"\"\n        ## Some Heading\n\n        With Data:\n        - Foo\n        - Bar\n        \"\"\"),\n    )\n\n    # Breaks are added:\n    assert \"<br />\" in response\n    assert \"- Foo\" in response\n    assert \"- Bar\" in response\n\n    # Table formatting\n    response = convert_between(\n        NotifyFormat.MARKDOWN,\n        NotifyFormat.HTML,\n        cleandoc(\"\"\"\n        First Header   | Second Header\n        -------------- | -------------\n        Content Cell1  | Content Cell3\n        Content Cell2  | Content Cell4\n        \"\"\"),\n    )\n\n    assert \"<table>\" in response\n    assert \"<th>First Header</th>\" in response\n    assert \"<th>Second Header</th>\" in response\n    assert \"<td>Content Cell1</td>\" in response\n    assert \"<td>Content Cell2</td>\" in response\n    assert \"<td>Content Cell3</td>\" in response\n    assert \"<td>Content Cell4</td>\" in response\n"
  },
  {
    "path": "tests/test_decorator_notify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom os.path import dirname, join\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    AppriseAttachment,\n    AppriseConfig,\n    NotificationManager,\n    common,\n)\nfrom apprise.decorators import notify\nfrom apprise.decorators.base import CustomNotifyPlugin\n\nlogging.disable(logging.CRITICAL)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\nTEST_VAR_DIR = join(dirname(__file__), \"var\")\n\n\ndef test_notify_simple_decoration():\n    \"\"\"decorators: Test simple @notify\"\"\"\n\n    # Verify our schema we're about to declare doesn't already exist\n    # in our schema map:\n    assert \"utiltest\" not in N_MGR\n\n    verify_obj = {}\n\n    # Define a function here on the spot\n    @notify(on=\"utiltest\", name=\"Apprise @notify Decorator Testing\")\n    def my_inline_notify_wrapper(\n        body, title, notify_type, attach, *args, **kwargs\n    ):\n\n        # Test our body (always present)\n        assert isinstance(body, str)\n\n        # Ensure content is of type utf-8\n        assert isinstance(body.encode(\"utf-8\"), bytes)\n\n        if attach:\n            # attachment is always of type AppriseAttach\n            assert isinstance(attach, AppriseAttachment)\n\n        # Populate our object we can use to validate\n        verify_obj.update({\n            \"body\": body,\n            \"title\": title,\n            \"notify_type\": notify_type,\n            \"attach\": attach,\n            \"args\": args,\n            \"kwargs\": kwargs,\n        })\n\n    # Now after our hook being inline... it's been loaded\n    assert \"utiltest\" in N_MGR\n\n    # Create ourselves an apprise object\n    aobj = Apprise()\n\n    assert aobj.add(\"utiltest://\") is True\n\n    assert len(verify_obj) == 0\n\n    assert (\n        aobj.notify(\n            \"Hello World\",\n            title=\"My Title\",\n            # add some attachments too\n            attach=(\n                join(TEST_VAR_DIR, \"apprise-test.gif\"),\n                join(TEST_VAR_DIR, \"apprise-test.png\"),\n            ),\n        )\n        is True\n    )\n\n    # Our content was populated after the notify() call\n    assert len(verify_obj) > 0\n    assert verify_obj[\"body\"] == \"Hello World\"\n    assert verify_obj[\"title\"] == \"My Title\"\n    assert verify_obj[\"notify_type\"] == common.NotifyType.INFO\n    assert isinstance(verify_obj[\"attach\"], AppriseAttachment)\n    assert len(verify_obj[\"attach\"]) == 2\n\n    # No format was defined\n    assert \"body_format\" in verify_obj[\"kwargs\"]\n    assert verify_obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert isinstance(verify_obj[\"kwargs\"], dict)\n    assert \"meta\" in verify_obj[\"kwargs\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"], dict)\n    assert len(verify_obj[\"kwargs\"][\"meta\"]) == 4\n    assert \"tag\" in verify_obj[\"kwargs\"][\"meta\"]\n\n    assert \"asset\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"asset\"], AppriseAsset)\n\n    assert verify_obj[\"kwargs\"][\"meta\"][\"schema\"] == \"utiltest\"\n    assert verify_obj[\"kwargs\"][\"meta\"][\"url\"] == \"utiltest://\"\n\n    # Reset our verify object (so it can be populated again)\n    verify_obj = {}\n\n    # Send unicode\n    assert aobj.notify(\"ツ\".encode()) is True\n    # Our content was populated after the notify() call\n    assert len(verify_obj) > 0\n    assert verify_obj[\"body\"] == \"ツ\"  # content comes back as str (utf-8)\n    assert verify_obj[\"title\"] == \"\"\n    assert verify_obj[\"notify_type\"] == common.NotifyType.INFO\n    assert verify_obj[\"attach\"] is None\n\n    # No format was defined\n    assert \"body_format\" in verify_obj[\"kwargs\"]\n    assert verify_obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert isinstance(verify_obj[\"kwargs\"], dict)\n    assert \"meta\" in verify_obj[\"kwargs\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"], dict)\n    assert len(verify_obj[\"kwargs\"][\"meta\"]) == 4\n    assert \"tag\" in verify_obj[\"kwargs\"][\"meta\"]\n\n    assert \"asset\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"asset\"], AppriseAsset)\n\n    assert verify_obj[\"kwargs\"][\"meta\"][\"schema\"] == \"utiltest\"\n    assert verify_obj[\"kwargs\"][\"meta\"][\"url\"] == \"utiltest://\"\n\n    # Reset our verify object (so it can be populated again)\n    verify_obj = {}\n\n    # Send utf-8 string\n    assert aobj.notify(\"ツ\") is True\n\n    assert len(verify_obj) > 0\n    assert verify_obj[\"body\"] == \"ツ\"  # content comes back as str (utf-8)\n    assert verify_obj[\"title\"] == \"\"\n    assert verify_obj[\"notify_type\"] == common.NotifyType.INFO\n    assert verify_obj[\"attach\"] is None\n\n    # No format was defined\n    assert \"body_format\" in verify_obj[\"kwargs\"]\n    assert verify_obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert isinstance(verify_obj[\"kwargs\"], dict)\n    assert \"meta\" in verify_obj[\"kwargs\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"], dict)\n    assert len(verify_obj[\"kwargs\"][\"meta\"]) == 4\n    assert \"tag\" in verify_obj[\"kwargs\"][\"meta\"]\n\n    assert \"asset\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"asset\"], AppriseAsset)\n\n    assert verify_obj[\"kwargs\"][\"meta\"][\"schema\"] == \"utiltest\"\n    assert verify_obj[\"kwargs\"][\"meta\"][\"url\"] == \"utiltest://\"\n\n    # Some cases that will fail internal validation:\n    # - No Body\n    assert aobj.notify(\"\") is False\n    # - Title only\n    assert aobj.notify(\"\", title=\"hello world!\") is False\n\n    # Reset our verify object (so it can be populated again)\n    verify_obj = {}\n\n    # No Body but has attachment (valid)\n    assert (\n        aobj.notify(\"\", attach=(join(TEST_VAR_DIR, \"apprise-test.png\"),))\n        is True\n    )\n\n    # Our content was populated after the notify() call\n    assert len(verify_obj) > 0\n    assert verify_obj[\"body\"] == \"\"\n    assert verify_obj[\"title\"] == \"\"\n    assert verify_obj[\"notify_type\"] == common.NotifyType.INFO\n    assert isinstance(verify_obj[\"attach\"], AppriseAttachment)\n    assert len(verify_obj[\"attach\"]) == 1\n\n    # No format was defined\n    assert \"body_format\" in verify_obj[\"kwargs\"]\n    assert verify_obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert isinstance(verify_obj[\"kwargs\"], dict)\n    assert \"meta\" in verify_obj[\"kwargs\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"], dict)\n    assert len(verify_obj[\"kwargs\"][\"meta\"]) == 4\n    assert \"tag\" in verify_obj[\"kwargs\"][\"meta\"]\n\n    assert \"asset\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"asset\"], AppriseAsset)\n\n    assert verify_obj[\"kwargs\"][\"meta\"][\"schema\"] == \"utiltest\"\n    assert verify_obj[\"kwargs\"][\"meta\"][\"url\"] == \"utiltest://\"\n\n    # Reset our verify object (so it can be populated again)\n    verify_obj = {}\n\n    # We'll do another test now\n    assert (\n        aobj.notify(\n            \"Hello Another World\",\n            title=\"My Other Title\",\n            body_format=common.NotifyFormat.HTML,\n            notify_type=common.NotifyType.WARNING,\n        )\n        is True\n    )\n\n    # Our content was populated after the notify() call\n    assert len(verify_obj) > 0\n    assert verify_obj[\"body\"] == \"Hello Another World\"\n    assert verify_obj[\"title\"] == \"My Other Title\"\n    assert verify_obj[\"notify_type\"] == common.NotifyType.WARNING\n    # We have no attachments\n    assert verify_obj[\"attach\"] is None\n\n    # No format was defined\n    assert \"body_format\" in verify_obj[\"kwargs\"]\n    assert verify_obj[\"kwargs\"][\"body_format\"] == common.NotifyFormat.HTML\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert \"meta\" in verify_obj[\"kwargs\"]\n    assert isinstance(verify_obj[\"kwargs\"], dict)\n    assert len(verify_obj[\"kwargs\"][\"meta\"]) == 4\n    assert \"asset\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"asset\"], AppriseAsset)\n    assert \"tag\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"tag\"], set)\n    assert verify_obj[\"kwargs\"][\"meta\"][\"schema\"] == \"utiltest\"\n    assert verify_obj[\"kwargs\"][\"meta\"][\"url\"] == \"utiltest://\"\n\n    assert \"notexc\" not in N_MGR\n\n    # Define a function here on the spot\n    @notify(on=\"notexc\", name=\"Apprise @notify Exception Handling\")\n    def my_exception_inline_notify_wrapper(\n        body, title, notify_type, attach, *args, **kwargs\n    ):\n        raise ValueError(\"An exception was thrown!\")\n\n    assert \"notexc\" in N_MGR\n\n    # Create ourselves an apprise object\n    aobj = Apprise()\n\n    assert aobj.add(\"notexc://\") is True\n\n    # Isn't handled\n    assert aobj.notify(\"Exceptions will be thrown!\") is False\n\n    # Tidy\n    N_MGR.remove(\"utiltest\", \"notexc\")\n\n\ndef test_notify_complex_decoration():\n    \"\"\"decorators: Test complex @notify\"\"\"\n\n    # Verify our schema we're about to declare doesn't already exist\n    # in our schema map:\n    assert \"utiltest\" not in N_MGR\n\n    verify_obj = {}\n\n    # Define a function here on the spot\n    @notify(\n        on=\"utiltest://user@myhost:23?key=value&NOT=CaseSensitive\",\n        name=\"Apprise @notify Decorator Testing\",\n    )\n    def my_inline_notify_wrapper(\n        body, title, notify_type, attach, *args, **kwargs\n    ):\n\n        # Populate our object we can use to validate\n        verify_obj.update({\n            \"body\": body,\n            \"title\": title,\n            \"notify_type\": notify_type,\n            \"attach\": attach,\n            \"args\": args,\n            \"kwargs\": kwargs,\n        })\n\n    # Now after our hook being inline... it's been loaded\n    assert \"utiltest\" in N_MGR\n\n    # Create ourselves an apprise object\n    aobj = Apprise()\n\n    assert aobj.add(\"utiltest://\") is True\n\n    assert len(verify_obj) == 0\n\n    assert (\n        aobj.notify(\n            \"Hello World\",\n            title=\"My Title\",\n            # add some attachments too\n            attach=(\n                join(TEST_VAR_DIR, \"apprise-test.gif\"),\n                join(TEST_VAR_DIR, \"apprise-test.png\"),\n            ),\n        )\n        is True\n    )\n\n    # Our content was populated after the notify() call\n    assert len(verify_obj) > 0\n    assert verify_obj[\"body\"] == \"Hello World\"\n    assert verify_obj[\"title\"] == \"My Title\"\n    assert verify_obj[\"notify_type\"] == common.NotifyType.INFO\n    assert isinstance(verify_obj[\"attach\"], AppriseAttachment)\n    assert len(verify_obj[\"attach\"]) == 2\n\n    # No format was defined\n    assert \"body_format\" in verify_obj[\"kwargs\"]\n    assert verify_obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert isinstance(verify_obj[\"kwargs\"], dict)\n    assert \"meta\" in verify_obj[\"kwargs\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"], dict)\n\n    assert \"asset\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"asset\"], AppriseAsset)\n\n    assert \"tag\" in verify_obj[\"kwargs\"][\"meta\"]\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"tag\"], set)\n\n    assert len(verify_obj[\"kwargs\"][\"meta\"]) == 8\n    # We carry all of our default arguments from the @notify's initialization\n    assert verify_obj[\"kwargs\"][\"meta\"][\"schema\"] == \"utiltest\"\n\n    # Case sensitivity is lost on key assignment and always made lowercase\n    # however value case sensitivity is preseved.\n    # this is the assembled URL based on the combined values of the default\n    # parameters with values provided in the URL (user's configuration)\n    assert verify_obj[\"kwargs\"][\"meta\"][\"url\"].startswith(\n        \"utiltest://user@myhost:23?\"\n    )\n\n    # We don't know where they get placed, so just search for their match\n    assert \"key=value\" in verify_obj[\"kwargs\"][\"meta\"][\"url\"]\n    assert \"not=CaseSensitive\" in verify_obj[\"kwargs\"][\"meta\"][\"url\"]\n\n    # Reset our verify object (so it can be populated again)\n    verify_obj = {}\n\n    # We'll do another test now\n    aobj = Apprise()\n\n    assert aobj.add(\"utiltest://customhost?key=new&key2=another\") is True\n\n    assert len(verify_obj) == 0\n\n    # Send our notification\n    assert aobj.notify(\"Hello World\", title=\"My Title\") is True\n\n    # Our content was populated after the notify() call\n    assert len(verify_obj) > 0\n    assert verify_obj[\"body\"] == \"Hello World\"\n    assert verify_obj[\"title\"] == \"My Title\"\n    assert verify_obj[\"notify_type\"] == common.NotifyType.INFO\n    assert verify_obj[\"attach\"] is None\n\n    # No format was defined\n    assert \"body_format\" in verify_obj[\"kwargs\"]\n    assert verify_obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert \"meta\" in verify_obj[\"kwargs\"]\n    assert isinstance(verify_obj[\"kwargs\"], dict)\n    assert len(verify_obj[\"kwargs\"][\"meta\"]) == 8\n\n    # We carry all of our default arguments from the @notify's initialization\n    assert verify_obj[\"kwargs\"][\"meta\"][\"schema\"] == \"utiltest\"\n    # Our host get's correctly over-ridden\n    assert verify_obj[\"kwargs\"][\"meta\"][\"host\"] == \"customhost\"\n\n    assert verify_obj[\"kwargs\"][\"meta\"][\"user\"] == \"user\"\n    assert verify_obj[\"kwargs\"][\"meta\"][\"port\"] == 23\n    assert isinstance(verify_obj[\"kwargs\"][\"meta\"][\"qsd\"], dict)\n    assert len(verify_obj[\"kwargs\"][\"meta\"][\"qsd\"]) == 3\n    # our key is over-ridden\n    assert verify_obj[\"kwargs\"][\"meta\"][\"qsd\"][\"key\"] == \"new\"\n    # Our other keys are preserved\n    assert verify_obj[\"kwargs\"][\"meta\"][\"qsd\"][\"not\"] == \"CaseSensitive\"\n    # New keys are added\n    assert verify_obj[\"kwargs\"][\"meta\"][\"qsd\"][\"key2\"] == \"another\"\n\n    # Case sensitivity is lost on key assignment and always made lowercase\n    # however value case sensitivity is preseved.\n    # this is the assembled URL based on the combined values of the default\n    # parameters with values provided in the URL (user's configuration)\n    assert verify_obj[\"kwargs\"][\"meta\"][\"url\"].startswith(\n        \"utiltest://user@customhost:23?\"\n    )\n\n    # We don't know where they get placed, so just search for their match\n    assert \"key=new\" in verify_obj[\"kwargs\"][\"meta\"][\"url\"]\n    assert \"not=CaseSensitive\" in verify_obj[\"kwargs\"][\"meta\"][\"url\"]\n    assert \"key2=another\" in verify_obj[\"kwargs\"][\"meta\"][\"url\"]\n\n    # Tidy\n    N_MGR.remove(\"utiltest\")\n\n\ndef test_notify_decorator_urls_with_space():\n    \"\"\"decorators: URLs containing spaces\"\"\"\n    # This is in relation to https://github.com/caronc/apprise/issues/1264\n\n    # Verify our schema we're about to declare doesn't already exist\n    # in our schema map:\n    assert \"post\" not in N_MGR\n\n    verify_obj = []\n\n    @notify(on=\"posts\")\n    def apprise_custom_api_call_wrapper(\n        body, title, notify_type, attach, meta, *args, **kwargs\n    ):\n\n        # Track what is added\n        verify_obj.append({\n            \"body\": body,\n            \"title\": title,\n            \"notify_type\": notify_type,\n            \"attach\": attach,\n            \"meta\": meta,\n            \"args\": args,\n            \"kwargs\": kwargs,\n        })\n\n    assert \"posts\" in N_MGR\n\n    # Create ourselves an apprise object\n    aobj = Apprise()\n\n    # Add our configuration\n    aobj.add(\"posts://example.com/my endpoint?-token=ab cdefg\")\n\n    # We loaded 1 item\n    assert len(aobj) == 1\n\n    # Nothing stored yet in our object\n    assert len(verify_obj) == 0\n\n    # Send utf-8 characters\n    assert aobj.notify(\"ツ\".encode(), title=\"My Title\") is True\n\n    # Service notified\n    assert len(verify_obj) == 1\n\n    # Extract our object\n    obj = verify_obj.pop()\n\n    assert obj.get(\"body\") == \"ツ\"\n    assert obj.get(\"title\") == \"My Title\"\n    assert obj.get(\"notify_type\") == \"info\"\n    assert obj.get(\"attach\") is None\n    assert isinstance(obj.get(\"args\"), tuple)\n    assert len(obj.get(\"args\")) == 0\n    assert obj.get(\"kwargs\") == {\"body_format\": None}\n    meta = obj.get(\"meta\")\n    assert isinstance(meta, dict)\n\n    assert meta.get(\"schema\") == \"posts\"\n    assert (\n        meta.get(\"url\") == \"posts://example.com/my%20endpoint?-token=ab+cdefg\"\n    )\n    assert meta.get(\"qsd\") == {\"-token\": \"ab cdefg\"}\n    assert meta.get(\"host\") == \"example.com\"\n    assert meta.get(\"fullpath\") == \"/my%20endpoint\"\n    assert meta.get(\"path\") == \"/\"\n    assert meta.get(\"query\") == \"my%20endpoint\"\n    assert isinstance(meta.get(\"tag\"), set)\n    assert len(meta.get(\"tag\")) == 0\n    assert isinstance(meta.get(\"asset\"), AppriseAsset)\n\n    # Tidy\n    N_MGR.remove(\"posts\")\n\n\ndef test_notify_multi_instance_decoration(tmpdir):\n    \"\"\"decorators: Test multi-instance @notify\"\"\"\n\n    # Verify our schema we're about to declare doesn't already exist\n    # in our schema map:\n    assert \"multi\" not in N_MGR\n\n    verify_obj = []\n\n    # Define a function here on the spot\n    @notify(on=\"multi\", name=\"Apprise @notify Decorator Testing\")\n    def my_inline_notify_wrapper(\n        body, title, notify_type, attach, meta, *args, **kwargs\n    ):\n\n        assert isinstance(body, str)\n\n        # Track what is added\n        verify_obj.append({\n            \"body\": body,\n            \"title\": title,\n            \"notify_type\": notify_type,\n            \"attach\": attach,\n            \"meta\": meta,\n            \"args\": args,\n            \"kwargs\": kwargs,\n        })\n\n    # Now after our hook being inline... it's been loaded\n    assert \"multi\" in N_MGR\n\n    # Prepare our config\n    t = tmpdir.mkdir(\"multi-test\").join(\"apprise.yml\")\n    t.write(\"\"\"urls:\n    - multi://user1:pass@hostname\n    - multi://user2:pass2@hostname?verify=no\n    \"\"\")\n\n    # Create ourselves a config object\n    ac = AppriseConfig(paths=str(t))\n\n    # Create ourselves an apprise object\n    aobj = Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # The number of configuration files that exist\n    assert len(ac) == 1\n\n    # 2 notification endpoints are loaded\n    assert len(ac.servers()) == 2\n\n    # Nothing stored yet in our object\n    assert len(verify_obj) == 0\n\n    # Send utf-8 characters\n    assert aobj.notify(\"ツ\".encode(), title=\"My Title\") is True\n\n    assert len(verify_obj) == 2\n\n    # Python 3.6 does not nessisarily return list in order\n    # So let's be sure it's sorted by the user id field to make the remaining\n    # checks on this test easy\n    verify_obj = sorted(verify_obj, key=lambda x: x[\"meta\"][\"user\"])\n\n    # Our content was populated after the notify() call\n    obj = verify_obj[0]\n    assert obj[\"body\"] == \"ツ\"\n    assert obj[\"title\"] == \"My Title\"\n    assert obj[\"notify_type\"] == common.NotifyType.INFO\n\n    meta = obj[\"meta\"]\n    assert isinstance(meta, dict)\n\n    # No format was defined\n    assert \"body_format\" in obj[\"kwargs\"]\n    assert obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert isinstance(obj[\"kwargs\"], dict)\n\n    assert \"asset\" in meta\n    assert isinstance(meta[\"asset\"], AppriseAsset)\n\n    assert \"tag\" in meta\n    assert isinstance(meta[\"tag\"], set)\n\n    assert len(meta) == 8\n    # We carry all of our default arguments from the @notify's initialization\n    assert meta[\"schema\"] == \"multi\"\n    assert meta[\"host\"] == \"hostname\"\n    assert meta[\"user\"] == \"user1\"\n    assert meta[\"verify\"] is True\n    assert meta[\"password\"] == \"pass\"\n\n    # Verify our URL is correct\n    assert meta[\"url\"] == \"multi://user1:pass@hostname\"\n\n    #\n    # Now verify our second URL saved correct\n    #\n\n    # Our content was populated after the notify() call\n    obj = verify_obj[1]\n    assert obj[\"body\"] == \"ツ\"\n    assert obj[\"title\"] == \"My Title\"\n    assert obj[\"notify_type\"] == common.NotifyType.INFO\n\n    meta = obj[\"meta\"]\n    assert isinstance(meta, dict)\n\n    # No format was defined\n    assert \"body_format\" in obj[\"kwargs\"]\n    assert obj[\"kwargs\"][\"body_format\"] is None\n\n    # The meta argument allows us to further parse the URL parameters\n    # specified\n    assert isinstance(obj[\"kwargs\"], dict)\n\n    assert \"asset\" in meta\n    assert isinstance(meta[\"asset\"], AppriseAsset)\n\n    assert \"tag\" in meta\n    assert isinstance(meta[\"tag\"], set)\n\n    assert len(meta) == 9\n    # We carry all of our default arguments from the @notify's initialization\n    assert meta[\"schema\"] == \"multi\"\n    assert meta[\"host\"] == \"hostname\"\n    assert meta[\"user\"] == \"user2\"\n    assert meta[\"password\"] == \"pass2\"\n    assert meta[\"verify\"] is False\n    assert meta[\"qsd\"][\"verify\"] == \"no\"\n\n    # Verify our URL is correct\n    assert meta[\"url\"] == \"multi://user2:pass2@hostname?verify=no\"\n\n    # Tidy\n    N_MGR.remove(\"multi\")\n\n\ndef test_custom_notify_plugin_decoration():\n    \"\"\"decorators: CustomNotifyPlugin testing\"\"\"\n\n    CustomNotifyPlugin()\n"
  },
  {
    "path": "tests/test_escapes.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\nfrom unittest import mock\n\nimport requests\n\nimport apprise\n\n\n@mock.patch(\"requests.request\")\ndef test_apprise_interpret_escapes(mock_request):\n    \"\"\"\n    API: Apprise() interpret-escape tests\n    \"\"\"\n\n    # Prepare Mock\n    mock_request.return_value = requests.Request()\n    mock_request.return_value.status_code = requests.codes.ok\n\n    # Default Escapes interpretation Mode is set to disable\n    asset = apprise.AppriseAsset()\n    assert asset.interpret_escapes is False\n\n    # Load our asset\n    a = apprise.Apprise(asset=asset)\n\n    # add a test server\n    assert a.add(\"json://localhost\") is True\n\n    # Our servers should carry this flag\n    assert a[0].asset.interpret_escapes is False\n\n    # Send notification\n    assert a.notify(\"ab\\\\ncd\") is True\n\n    # Test our call count\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n\n    # content is not escaped\n    assert loads(details[1][\"data\"]).get(\"message\", \"\") == \"ab\\\\ncd\"\n\n    # Reset\n    mock_request.reset_mock()\n\n    # Send notification and provide override:\n    assert a.notify(\"ab\\\\ncd\", interpret_escapes=True) is True\n\n    # Test our call count\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n\n    # content IS escaped\n    assert loads(details[1][\"data\"]).get(\"message\", \"\") == \"ab\\ncd\"\n\n    # Reset\n    mock_request.reset_mock()\n\n    #\n    #  Now we test the reverse setup where we set the AppriseAsset\n    #  object to True but force it off through the notify() calls\n    #\n\n    # Default Escapes interpretation Mode is set to disable\n    asset = apprise.AppriseAsset(interpret_escapes=True)\n    assert asset.interpret_escapes is True\n\n    # Load our asset\n    a = apprise.Apprise(asset=asset)\n\n    # add a test server\n    assert a.add(\"json://localhost\") is True\n\n    # Our servers should carry this flag\n    assert a[0].asset.interpret_escapes is True\n\n    # Send notification\n    assert a.notify(\"ab\\\\ncd\") is True\n\n    # Test our call count\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n\n    # content IS escaped\n    assert loads(details[1][\"data\"]).get(\"message\", \"\") == \"ab\\ncd\"\n\n    # Reset\n    mock_request.reset_mock()\n\n    # Send notification and provide override:\n    assert a.notify(\"ab\\\\ncd\", interpret_escapes=False) is True\n\n    # Test our call count\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n\n    # content is NOT escaped\n    assert loads(details[1][\"data\"]).get(\"message\", \"\") == \"ab\\\\ncd\"\n\n\n@mock.patch(\"requests.request\")\ndef test_apprise_escaping(mock_request):\n    \"\"\"\n    API: Apprise() escaping tests\n\n    \"\"\"\n    a = apprise.Apprise()\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n    mock_request.return_value = response\n\n    # Create ourselves a test object to work with\n    a.add(\"json://localhost\")\n\n    # Escape our content\n    assert a.notify(\n        title=\"\\\\r\\\\ntitle\\\\r\\\\n\",\n        body=\"\\\\r\\\\nbody\\\\r\\\\n\",\n        interpret_escapes=True,\n    )\n\n    # Verify our content was escaped correctly\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    result = loads(details[1][\"data\"])\n    assert result[\"title\"] == \"title\"\n    assert result[\"message\"] == \"\\r\\nbody\"\n\n    # Reset our mock object\n    mock_request.reset_mock()\n\n    #\n    # Support Specially encoded content:\n    #\n\n    # Escape our content\n    assert a.notify(\n        # Google Translated to Arabic: \"Let's make the world a better place.\"\n        title=\"دعونا نجعل العالم مكانا أفضل.\\\\r\\\\t\\\\t\\\\n\\\\r\\\\n\",\n        # Google Translated to Hungarian: \"One line of code at a time.'\n        body=\"Egy sor kódot egyszerre.\\\\r\\\\n\\\\r\\\\r\\\\n\",\n        # Our Escape Flag\n        interpret_escapes=True,\n    )\n\n    # Verify our content was escaped correctly\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    result = loads(details[1][\"data\"])\n    assert result[\"title\"] == \"دعونا نجعل العالم مكانا أفضل.\"\n    assert result[\"message\"] == \"Egy sor kódot egyszerre.\"\n\n    # Error handling\n    #\n    # We can't escape the content below\n    assert a.notify(title=None, body=4, interpret_escapes=True) is False\n    assert a.notify(title=4, body=None, interpret_escapes=True) is False\n    assert (\n        a.notify(title=object(), body=False, interpret_escapes=True) is False\n    )\n    assert (\n        a.notify(title=False, body=object(), interpret_escapes=True) is False\n    )\n\n    # We support bytes\n    assert (\n        a.notify(\n            title=b\"byte title\", body=b\"byte body\", interpret_escapes=True\n        )\n        is True\n    )\n\n    # However they're escaped as 'utf-8' by default unless we tell Apprise\n    # otherwise\n    # Now test hebrew types (outside of default utf-8)\n    # כותרת נפלאה translates to 'A wonderful title'\n    # זו הודעה translates to 'This is a notification'\n    title = \"כותרת נפלאה\".encode(\"ISO-8859-8\")\n    body = \"[_[זו הודעה](http://localhost)_\".encode(\"ISO-8859-8\")\n    assert a.notify(title=title, body=body, interpret_escapes=True) is False\n\n    # However if we let Apprise know in advance the encoding, it will handle\n    # it for us\n    asset = apprise.AppriseAsset(encoding=\"ISO-8859-8\")\n    a = apprise.Apprise(asset=asset)\n    # Create ourselves a test object to work with\n    a.add(\"json://localhost\")\n    assert a.notify(title=title, body=body, interpret_escapes=True) is True\n\n    # We'll restore our configuration back to how it was now\n    a = apprise.Apprise()\n    a.add(\"json://localhost\")\n\n    # The body is proessed first, so the errors thrown above get tested on\n    # the body only.  Now we run similar tests but only make the title\n    # bad and always mark the body good\n    assert a.notify(title=None, body=\"valid\", interpret_escapes=True) is True\n    assert a.notify(title=4, body=\"valid\", interpret_escapes=True) is False\n    assert (\n        a.notify(title=object(), body=\"valid\", interpret_escapes=True) is False\n    )\n    assert a.notify(title=False, body=\"valid\", interpret_escapes=True) is True\n    # Bytes are supported\n    assert (\n        a.notify(title=b\"byte title\", body=\"valid\", interpret_escapes=True)\n        is True\n    )\n"
  },
  {
    "path": "tests/test_logger.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport os\nimport re\nimport sys\nfrom unittest import mock\n\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAsset, URLBase\n\n# Disable logging for a cleaner testing output\nfrom apprise.logger import LogCapture, logger, logging\n\n\ndef test_apprise_logger():\n    \"\"\"\n    API: Apprise() Logger\n\n    \"\"\"\n\n    # Ensure we're not running in a disabled state\n    logging.disable(logging.NOTSET)\n\n    # Set our log level\n    URLBase.logger.setLevel(logging.DEPRECATE + 1)\n\n    # Deprication will definitely not trigger\n    URLBase.logger.deprecate(\"test\")\n\n    # Verbose Debugging is not on at this point\n    URLBase.logger.trace(\"test\")\n\n    # Set both logging entries on\n    URLBase.logger.setLevel(logging.TRACE)\n\n    # Deprication will definitely trigger\n    URLBase.logger.deprecate(\"test\")\n\n    # Verbose Debugging will activate\n    URLBase.logger.trace(\"test\")\n\n    # Disable Logging\n    logging.disable(logging.CRITICAL)\n\n\ndef test_apprise_log_memory_captures():\n    \"\"\"\n    API: Apprise() Log Memory Captures\n\n    \"\"\"\n\n    # Ensure we're not running in a disabled state\n    logging.disable(logging.NOTSET)\n\n    logger.setLevel(logging.CRITICAL)\n    with LogCapture(level=logging.TRACE) as stream:\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        logs = re.split(r\"\\r*\\n\", stream.getvalue().rstrip())\n\n        # We have a log entry for each of the 6 logs we generated above\n        assert \"trace\" in stream.getvalue()\n        assert \"debug\" in stream.getvalue()\n        assert \"info\" in stream.getvalue()\n        assert \"warning\" in stream.getvalue()\n        assert \"error\" in stream.getvalue()\n        assert \"deprecate\" in stream.getvalue()\n        assert len(logs) == 6\n\n    # Verify that we did not lose our effective log level even though\n    # the above steps the level up for the duration of the capture\n    assert logger.getEffectiveLevel() == logging.CRITICAL\n\n    logger.setLevel(logging.TRACE)\n    with LogCapture(level=logging.DEBUG) as stream:\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        # We have a log entry for 5 of the log entries we generated above\n        # There will be no 'trace' entry\n        assert \"trace\" not in stream.getvalue()\n        assert \"debug\" in stream.getvalue()\n        assert \"info\" in stream.getvalue()\n        assert \"warning\" in stream.getvalue()\n        assert \"error\" in stream.getvalue()\n        assert \"deprecate\" in stream.getvalue()\n\n        logs = re.split(r\"\\r*\\n\", stream.getvalue().rstrip())\n        assert len(logs) == 5\n\n    # Verify that we did not lose our effective log level even though\n    # the above steps the level up for the duration of the capture\n    assert logger.getEffectiveLevel() == logging.TRACE\n\n    logger.setLevel(logging.ERROR)\n    with LogCapture(level=logging.WARNING) as stream:\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        # We have a log entry for 3 of the log entries we generated above\n        # There will be no 'trace', 'debug', or 'info' entry\n        assert \"trace\" not in stream.getvalue()\n        assert \"debug\" not in stream.getvalue()\n        assert \"info\" not in stream.getvalue()\n        assert \"warning\" in stream.getvalue()\n        assert \"error\" in stream.getvalue()\n        assert \"deprecate\" in stream.getvalue()\n\n        logs = re.split(r\"\\r*\\n\", stream.getvalue().rstrip())\n        assert len(logs) == 3\n\n    # Set a global level of ERROR\n    logger.setLevel(logging.ERROR)\n\n    # Use the default level of None (by not specifying one); we then\n    # use whatever has been defined globally\n    with LogCapture() as stream:\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        assert \"trace\" not in stream.getvalue()\n        assert \"debug\" not in stream.getvalue()\n        assert \"info\" not in stream.getvalue()\n        assert \"warning\" not in stream.getvalue()\n        assert \"error\" in stream.getvalue()\n        assert \"deprecate\" in stream.getvalue()\n\n        logs = re.split(r\"\\r*\\n\", stream.getvalue().rstrip())\n        assert len(logs) == 2\n\n    # Verify that we did not lose our effective log level\n    assert logger.getEffectiveLevel() == logging.ERROR\n\n    with LogCapture(level=logging.TRACE) as stream:\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        # We have a log entry for each of the 6 logs we generated above\n        assert \"trace\" in stream.getvalue()\n        assert \"debug\" in stream.getvalue()\n        assert \"info\" in stream.getvalue()\n        assert \"warning\" in stream.getvalue()\n        assert \"error\" in stream.getvalue()\n        assert \"deprecate\" in stream.getvalue()\n\n        logs = re.split(r\"\\r*\\n\", stream.getvalue().rstrip())\n        assert len(logs) == 6\n\n    # Verify that we did not lose our effective log level even though\n    # the above steps the level up for the duration of the capture\n    assert logger.getEffectiveLevel() == logging.ERROR\n\n    # Test capture where our notification throws an unhandled exception\n    obj = Apprise.instantiate(\"json://user:password@example.com\")\n    with (\n        mock.patch(\"requests.request\", side_effect=NotImplementedError()),\n        pytest.raises(NotImplementedError),\n        # Our exception gets caught in side our with() block\n        # and although raised, all graceful handling of the log\n        # is reverted as it was\n        LogCapture(level=logging.TRACE) as stream,\n    ):\n        obj.send(\"hello world\")\n\n    # Disable Logging\n    logging.disable(logging.CRITICAL)\n\n\ndef test_apprise_log_file_captures(tmpdir):\n    \"\"\"\n    API: Apprise() Log File Captures\n\n    \"\"\"\n\n    # Ensure we're not running in a disabled state\n    logging.disable(logging.NOTSET)\n\n    log_file = tmpdir.join(\"capture.log\")\n    assert not os.path.isfile(str(log_file))\n\n    logger.setLevel(logging.CRITICAL)\n    with LogCapture(path=str(log_file), level=logging.TRACE) as fp:\n        # The file will exit now\n        assert os.path.isfile(str(log_file))\n\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        content = fp.read().rstrip()\n        logs = re.split(r\"\\r*\\n\", content)\n\n        # We have a log entry for each of the 6 logs we generated above\n        assert \"trace\" in content\n        assert \"debug\" in content\n        assert \"info\" in content\n        assert \"warning\" in content\n        assert \"error\" in content\n        assert \"deprecate\" in content\n        assert len(logs) == 6\n\n    # The file is automatically cleaned up afterwards\n    assert not os.path.isfile(str(log_file))\n\n    # Verify that we did not lose our effective log level even though\n    # the above steps the level up for the duration of the capture\n    assert logger.getEffectiveLevel() == logging.CRITICAL\n\n    logger.setLevel(logging.TRACE)\n    with LogCapture(path=str(log_file), level=logging.DEBUG) as fp:\n        # The file will exit now\n        assert os.path.isfile(str(log_file))\n\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        content = fp.read().rstrip()\n        logs = re.split(r\"\\r*\\n\", content)\n\n        # We have a log entry for 5 of the log entries we generated above\n        # There will be no 'trace' entry\n        assert \"trace\" not in content\n        assert \"debug\" in content\n        assert \"info\" in content\n        assert \"warning\" in content\n        assert \"error\" in content\n        assert \"deprecate\" in content\n\n        assert len(logs) == 5\n\n        # Concurrent file access is not possible on Windows.\n        # PermissionError: [WinError 32] The process cannot access the file\n        # because it is being used by another process.\n        if sys.platform != \"win32\":\n            # Remove our file before we exit the with clause\n            # this causes our delete() call to throw gracefully inside\n            os.unlink(str(log_file))\n\n            # Verify file is gone\n            assert not os.path.isfile(str(log_file))\n\n    # Verify that we did not lose our effective log level even though\n    # the above steps the level up for the duration of the capture\n    assert logger.getEffectiveLevel() == logging.TRACE\n\n    logger.setLevel(logging.ERROR)\n    with LogCapture(\n        path=str(log_file), delete=False, level=logging.WARNING\n    ) as fp:\n\n        # Verify exists\n        assert os.path.isfile(str(log_file))\n\n        logger.trace(\"trace\")\n        logger.debug(\"debug\")\n        logger.info(\"info\")\n        logger.warning(\"warning\")\n        logger.error(\"error\")\n        logger.deprecate(\"deprecate\")\n\n        content = fp.read().rstrip()\n        logs = re.split(r\"\\r*\\n\", content)\n\n        # We have a log entry for 3 of the log entries we generated above\n        # There will be no 'trace', 'debug', or 'info' entry\n        assert \"trace\" not in content\n        assert \"debug\" not in content\n        assert \"info\" not in content\n        assert \"warning\" in content\n        assert \"error\" in content\n        assert \"deprecate\" in content\n\n        assert len(logs) == 3\n\n    # Verify the file still exists (because delete was set to False)\n    assert os.path.isfile(str(log_file))\n\n    # remove it now\n    os.unlink(str(log_file))\n\n    # Enure it's been removed\n    assert not os.path.isfile(str(log_file))\n\n    # Set a global level of ERROR\n    logger.setLevel(logging.ERROR)\n\n    # Test case where we can't open the file\n    with (\n        mock.patch(\"builtins.open\", side_effect=OSError()),\n        # Use the default level of None (by not specifying one); we then\n        # use whatever has been defined globally\n        pytest.raises(OSError),\n        LogCapture(path=str(log_file)) as fp,\n    ):\n\n        # we'll never get here because we'll fail to open the file\n        pass\n\n    # Disable Logging\n    logging.disable(logging.CRITICAL)\n\n\n@mock.patch(\"requests.request\")\ndef test_apprise_secure_logging(mock_request):\n    \"\"\"\n    API: Apprise() secure logging tests\n    \"\"\"\n\n    # Ensure we're not running in a disabled state\n    logging.disable(logging.NOTSET)\n\n    logger.setLevel(logging.CRITICAL)\n\n    # Prepare Mock\n    mock_request.return_value = requests.Request()\n    mock_request.return_value.status_code = requests.codes.ok\n\n    # Default Secure Logging is set to enabled\n    asset = AppriseAsset()\n    assert asset.secure_logging is True\n\n    # Load our asset\n    a = Apprise(asset=asset)\n\n    with LogCapture(level=logging.DEBUG) as stream:\n        # add a test server\n        assert a.add(\"json://user:pass1$-3!@localhost\") is True\n\n        # Our servers should carry this flag\n        assert a[0].asset.secure_logging is True\n\n        logs = re.split(r\"\\r*\\n\", stream.getvalue().rstrip())\n        assert len(logs) == 1\n        entry = re.split(r\"\\s-\\s\", logs[0])\n        assert len(entry) == 3\n        assert entry[1] == \"DEBUG\"\n        assert entry[2].startswith(\n            \"Loaded JSON URL: json://user:****@localhost/\"\n        )\n\n    # Send notification\n    assert a.notify(\"test\") is True\n\n    # Test our call count\n    assert mock_request.call_count == 1\n\n    # Reset\n    mock_request.reset_mock()\n\n    # Now we test the reverse configuration and turn off\n    # secure logging.\n\n    # Default Secure Logging is set to disable\n    asset = AppriseAsset(secure_logging=False)\n    assert asset.secure_logging is False\n\n    # Load our asset\n    a = Apprise(asset=asset)\n\n    with LogCapture(level=logging.DEBUG) as stream:\n        # add a test server\n        assert a.add(\"json://user:pass1$-3!@localhost\") is True\n\n        # Our servers should carry this flag\n        assert a[0].asset.secure_logging is False\n\n        logs = re.split(r\"\\r*\\n\", stream.getvalue().rstrip())\n        assert len(logs) == 1\n        entry = re.split(r\"\\s-\\s\", logs[0])\n        assert len(entry) == 3\n        assert entry[1] == \"DEBUG\"\n\n        # Note that our password is no longer escaped (it is however\n        # url encoded)\n        assert entry[2].startswith(\n            \"Loaded JSON URL: json://user:pass1%24-3%21@localhost/\"\n        )\n\n    # Disable Logging\n    logging.disable(logging.CRITICAL)\n"
  },
  {
    "path": "tests/test_notification_manager.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\n\n# Disable logging for a cleaner testing output\nimport logging\nimport re\nimport threading\nimport types\n\nimport pytest\n\nfrom apprise import Apprise, NotificationManager\nfrom apprise.plugins import NotifyBase\n\nlogging.disable(logging.CRITICAL)\n\n# Grant access to our Notification Manager Singleton\nN_MGR = NotificationManager()\n\n\ndef test_notification_manager_general():\n    \"\"\"\n    N_MGR: Notification Manager General testing\n\n    \"\"\"\n    # Clear our set so we can test init calls\n    N_MGR.unload_modules()\n    assert isinstance(N_MGR.schemas(), list)\n    assert len(N_MGR.schemas()) > 0\n    N_MGR.unload_modules(disable_native=True)\n    assert isinstance(N_MGR.schemas(), list)\n    assert len(N_MGR.schemas()) == 0\n\n    N_MGR.unload_modules()\n    assert len(N_MGR) > 0\n\n    N_MGR.unload_modules()\n    iter(N_MGR)\n    iter(N_MGR)\n\n    N_MGR.unload_modules()\n    assert bool(N_MGR) is False\n    assert len(list(iter(N_MGR))) > 0\n    assert bool(N_MGR)\n\n    N_MGR.unload_modules()\n    assert isinstance(N_MGR.plugins(), types.GeneratorType)\n    assert len(list(N_MGR.plugins())) > 0\n    N_MGR.unload_modules(disable_native=True)\n    assert isinstance(N_MGR.plugins(), types.GeneratorType)\n    assert len(list(N_MGR.plugins())) == 0\n    N_MGR.unload_modules()\n    assert isinstance(N_MGR[\"json\"](host=\"localhost\"), NotifyBase)\n    N_MGR.unload_modules()\n    assert \"json\" in N_MGR\n\n    # Define our good:// url\n    class DisabledNotification(NotifyBase):\n        # Always disabled\n        enabled = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        def url(self, **kwargs):\n            # Support url() function\n            return \"\"\n\n    # Define our good:// url\n    class GoodNotification(NotifyBase):\n\n        secure_protocol = (\"good\", \"goods\")\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(*args, **kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n        def url(self, **kwargs):\n            # Support url() function\n            return \"\"\n\n    N_MGR.unload_modules()\n    assert N_MGR.add(GoodNotification)\n    assert \"good\" in N_MGR\n    assert \"goods\" in N_MGR\n    assert \"abcd\" not in N_MGR\n    assert \"xyz\" not in N_MGR\n\n    N_MGR.unload_modules()\n    assert N_MGR.add(GoodNotification, \"abcd\")\n    assert \"good\" in N_MGR\n    assert \"goods\" in N_MGR\n    assert \"abcd\" in N_MGR\n    assert \"xyz\" not in N_MGR\n\n    N_MGR.unload_modules()\n    assert N_MGR.add(GoodNotification, [\"abcd\", \"xYz\"])\n    assert \"good\" in N_MGR\n    assert \"goods\" in N_MGR\n    assert \"abcd\" in N_MGR\n    # Lower case\n    assert \"xyz\" in N_MGR\n\n    N_MGR.unload_modules()\n    # Not going to work; schemas must be a list of string\n    assert N_MGR.add(GoodNotification, object) is False\n\n    N_MGR.unload_modules()\n    with pytest.raises(KeyError):\n        del N_MGR[\"good\"]\n    N_MGR[\"good\"] = GoodNotification\n    del N_MGR[\"good\"]\n\n    N_MGR.unload_modules()\n    N_MGR[\"good\"] = GoodNotification\n    assert N_MGR[\"good\"].enabled is True\n    N_MGR.enable_only(\"json\", \"xml\")\n    assert N_MGR[\"good\"].enabled is False\n    assert N_MGR[\"json\"].enabled is True\n    assert N_MGR[\"jsons\"].enabled is True\n    assert N_MGR[\"xml\"].enabled is True\n    assert N_MGR[\"xmls\"].enabled is True\n\n    # Only two plugins are enabled\n    assert len(list(N_MGR.plugins(include_disabled=False))) == 2\n\n    N_MGR.enable_only(\"good\")\n    assert N_MGR[\"good\"].enabled is True\n    assert N_MGR[\"json\"].enabled is False\n    assert N_MGR[\"jsons\"].enabled is False\n    assert N_MGR[\"xml\"].enabled is False\n    assert N_MGR[\"xmls\"].enabled is False\n\n    assert len(list(N_MGR.plugins(include_disabled=False))) == 1\n\n    N_MGR.unload_modules()\n    N_MGR[\"disabled\"] = DisabledNotification\n    assert N_MGR[\"disabled\"].enabled is False\n    N_MGR.enable_only(\"disabled\")\n    # Can't enable items that aren't supposed to be:\n    assert N_MGR[\"disabled\"].enabled is False\n\n    N_MGR[\"good\"] = GoodNotification\n    assert N_MGR[\"good\"].enabled is True\n\n    # You can't disable someething already disabled\n    N_MGR.disable(\"disabled\")\n    assert N_MGR[\"disabled\"].enabled is False\n\n    N_MGR.unload_modules()\n    N_MGR.enable_only(\"form\", \"xml\")\n    for schema in N_MGR.schemas(include_disabled=False):\n        assert re.match(r\"^(form|xml)s?$\", schema, re.IGNORECASE) is not None\n\n    N_MGR.unload_modules()\n    assert N_MGR[\"form\"].enabled is True\n    assert N_MGR[\"xml\"].enabled is True\n    assert N_MGR[\"json\"].enabled is True\n    N_MGR.enable_only(\"form\", \"xml\")\n    assert N_MGR[\"form\"].enabled is True\n    assert N_MGR[\"xml\"].enabled is True\n    assert N_MGR[\"json\"].enabled is False\n\n    N_MGR.disable(\"invalid\", \"xml\")\n    assert N_MGR[\"form\"].enabled is True\n    assert N_MGR[\"xml\"].enabled is False\n    assert N_MGR[\"json\"].enabled is False\n\n    # Detect that our json object is enabled\n    with pytest.raises(KeyError):\n        # The below can not be indexed\n        N_MGR[\"invalid\"]\n\n    N_MGR.unload_modules()\n    N_MGR.disable(\"invalid\", \"xml\")\n\n    N_MGR.unload_modules()\n    assert N_MGR[\"json\"].enabled is True\n\n    # Work with an empty module tree\n    N_MGR.unload_modules(disable_native=True)\n    with pytest.raises(KeyError):\n        # The below can not be indexed\n        N_MGR[\"good\"]\n\n    N_MGR.unload_modules()\n    assert \"hello\" not in N_MGR\n    assert \"good\" not in N_MGR\n    assert \"goods\" not in N_MGR\n\n    N_MGR[\"hello\"] = GoodNotification\n    assert \"hello\" in N_MGR\n    assert \"good\" in N_MGR\n    assert \"goods\" in N_MGR\n\n    N_MGR.unload_modules()\n    N_MGR[\"good\"] = GoodNotification\n\n    with pytest.raises(KeyError):\n        # Can not assign the value again without getting a Conflict\n        N_MGR[\"good\"] = GoodNotification\n\n    N_MGR.unload_modules()\n    N_MGR.remove(\"good\", \"invalid\")\n    assert \"good\" not in N_MGR\n    assert \"goods\" not in N_MGR\n\n\ndef test_notification_manager_add_force_overrides_schema_without_unload():\n    \"\"\"Verify add(force=True) overrides existing schema without unloading.\"\"\"\n    import sys\n\n    from apprise.plugins import N_MGR\n\n    class NotifyDiscordCustom:\n        \"\"\"A minimal custom discord override used for testing.\"\"\"\n\n        protocol = \"discord\"\n        secure_protocol = None\n        service_name = \"Discord (Custom)\"\n\n        def __init__(self, *args, **kwargs):\n            pass\n\n        def send(self, *args, **kwargs):\n            return True\n\n        def url(self, **kwargs):\n            return \"discord://\"\n\n    # Ensure native modules are loaded\n    N_MGR.unload_modules()\n    N_MGR.load_modules()\n\n    # Confirm 'discord' is available and capture its module name\n    assert \"discord\" in N_MGR\n    native_plugin = N_MGR[\"discord\"]\n    native_module = native_plugin.__module__\n    assert native_module in sys.modules\n\n    # A normal add should fail due to the conflict\n    assert N_MGR.add(NotifyDiscordCustom, schemas=\"discord\") is False\n\n    # A forced add should succeed and must not unload the native module\n    assert N_MGR.add(\n        NotifyDiscordCustom, schemas=\"discord\", force=True) is True\n    assert N_MGR[\"discord\"] is NotifyDiscordCustom\n    assert native_module in sys.modules\n\n\ndef test_notification_manager_module_loading(tmpdir):\n    \"\"\"\n    N_MGR: Notification Manager Module Loading\n\n    \"\"\"\n\n    # Handle loading modules twice (they gracefully handle not loading more in\n    # memory then needed)\n    N_MGR.load_modules()\n    N_MGR.load_modules()\n\n    #\n    # Thread Testing\n    #\n\n    # This tests against a racing condition when the modules have not been\n    # loaded.  When multiple instances of Apprise are all instantiated,\n    # the loading of the modules will occur for each instance if detected\n    # having not been previously done, this tests that we can dynamically\n    # support the loading of modules once whe multiple instances to apprise\n    # are instantiated.\n    thread_count = 10\n\n    def thread_test(result, no):\n        \"\"\"Load our apprise object with valid URLs and store our result.\"\"\"\n        apobj = Apprise()\n        result[no] = (\n            apobj.add(\"json://localhost\")\n            and apobj.add(\"form://localhost\")\n            and apobj.add(\"xml://localhost\")\n        )\n\n    # Unload our modules\n    N_MGR.unload_modules()\n\n    # Prepare threads to load\n    results = [None] * thread_count\n    threads = [\n        threading.Thread(target=thread_test, args=(results, no))\n        for no in range(thread_count)\n    ]\n\n    # Verify we can safely load our modules in a thread safe environment\n    for t in threads:\n        t.start()\n\n    for t in threads:\n        t.join()\n\n    # Verify we loaded our urls in all threads successfully\n    for result in results:\n        assert result is True\n\n\ndef test_notification_manager_decorators(tmpdir):\n    \"\"\"\n    N_MGR: Notification Manager Decorator testing\n\n    \"\"\"\n\n    # Prepare ourselves a file to work with\n    notify_hook = tmpdir.mkdir(\"goodmodule\").join(\"__init__.py\")\n    notify_hook.write(cleandoc(\"\"\"\n    from apprise.decorators import notify\n\n    # We want to trigger on anyone who configures a call to clihook://\n    @notify(on=\"clihooka\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        # A simple test - print to screen\n        print(\"A {}: {} - {}\".format(notify_type, title, body))\n\n        # No return (so a return of None) get's translated to True\n\n    # Define another in the same file; uppercase goes to lower\n    @notify(on=\"CLIhookb\")\n    def mywrapper(body, title, notify_type, *args, **kwargs):\n        # A simple test - print to screen\n        print(\"B {}: {} - {}\".format(notify_type, title, body))\n\n        # No return (so a return of None) get's translated to True\n    \"\"\"))\n\n    N_MGR.module_detection(str(notify_hook))\n\n    assert \"clihooka\" in N_MGR\n    assert \"clihookb\" in N_MGR\n    N_MGR.unload_modules()\n    assert \"clihooka\" not in N_MGR\n    assert \"clihookb\" not in N_MGR\n\n    N_MGR.module_detection(str(notify_hook))\n    assert \"clihooka\" in N_MGR\n    assert \"clihookb\" in N_MGR\n    del N_MGR[\"clihookb\"]\n    assert \"clihooka\" in N_MGR\n    assert \"clihookb\" not in N_MGR\n    del N_MGR[\"clihooka\"]\n    assert \"clihooka\" not in N_MGR\n    assert \"clihookb\" not in N_MGR\n\n    # Prepare ourselves a file to work with\n    notify_base = tmpdir.mkdir(\"plugins\")\n    notify_test = notify_base.join(\"NotifyTest.py\")\n    notify_test.write(cleandoc(\"\"\"\n    #\n    # Bare Minimum Valid Object\n    #\n    from apprise.plugins import NotifyBase\n    from apprise.common import NotifyType\n\n    class NotifyTest(NotifyBase):\n\n        service_name = 'Test'\n\n        # The services URL\n        service_url = 'https://github.com/caronc/apprise/'\n\n        # Define our protocol\n        secure_protocol = 'myservice'\n\n        # A URL that takes you to the setup/help of the specific protocol\n        setup_url = 'https://appriseit.com/services/myservice/'\n\n        # Define object templates\n        templates = (\n            '{schema}://',\n        )\n\n        def __init__(self, **kwargs):\n            super().__init__(**kwargs)\n\n        def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):\n            return True\n\n        def url(self):\n            return 'myservice://'\n    \"\"\"))\n    assert \"myservice\" not in N_MGR\n    N_MGR.load_modules(path=str(notify_base))\n    assert \"myservice\" in N_MGR\n    del N_MGR[\"myservice\"]\n    assert \"myservice\" not in N_MGR\n\n    assert \"myservice\" not in N_MGR\n    N_MGR.load_modules(path=str(notify_base))\n\n    # It's still not loaded because the path has already been scanned\n    assert \"myservice\" not in N_MGR\n    N_MGR.load_modules(path=str(notify_base), force=True)\n    assert \"myservice\" in N_MGR\n\n    # Double load will test section of code that prevents a notification\n    # From reloading if previously already loaded\n    N_MGR.load_modules(path=str(notify_base))\n    # Our item is still loaded as expected\n    assert \"myservice\" in N_MGR\n\n    # Simple test to make sure we can handle duplicate entries loaded\n    N_MGR.load_modules(path=str(notify_base), force=True)\n    N_MGR.load_modules(path=str(notify_base), force=True)\n\n\ndef test_notification_manager_add_force_returns_false_if_conflict_persists(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"\n    N_MGR.add(force=True) must still fail safely if conflicts persist after the\n    attempted unmap.\n\n    This explicitly targets the defensive re-check branch in add().\n    \"\"\"\n    # Ensure native modules are loaded and we have a known schema to collide on\n    N_MGR.unload_modules()\n    N_MGR.load_modules()\n    assert \"discord\" in N_MGR\n\n    class NotifyDiscordCustom:\n        protocol = \"discord\"\n        secure_protocol = None\n        service_name = \"Discord (Custom)\"\n\n        def __init__(self, *args, **kwargs) -> None:\n            pass\n\n        def send(self, *args, **kwargs) -> bool:\n            return True\n\n        def url(self, **kwargs) -> str:\n            return \"discord://\"\n\n    # Simulate an unmap failure by making remove() a no-op.\n    # This ensures the conflict remains on the re-check and triggers the\n    # warning + return False branch.\n    def _noop_remove(*args, **kwargs) -> None:\n        return None\n\n    monkeypatch.setattr(N_MGR, \"remove\", _noop_remove)\n\n    assert (\n        N_MGR.add(NotifyDiscordCustom, schemas=\"discord\", force=True) is False\n    )\n"
  },
  {
    "path": "tests/test_notify_base.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timedelta\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom timeit import default_timer\n\nimport pytest\n\nfrom apprise import AppriseAsset, NotifyImageSize, NotifyType\nfrom apprise.plugins import NotifyBase\n\nlogging.disable(logging.CRITICAL)\n\n\ndef test_notify_base():\n    \"\"\"\n    API: NotifyBase() object\n\n    \"\"\"\n\n    # invalid types throw exceptions\n    with pytest.raises(TypeError):\n        NotifyBase(**{\"format\": \"invalid\"})\n\n    # invalid types throw exceptions\n    with pytest.raises(TypeError):\n        NotifyBase(**{\"overflow\": \"invalid\"})\n\n    # Bad port information\n    nb = NotifyBase(port=\"invalid\")\n    assert nb.port is None\n\n    nb = NotifyBase(port=10)\n    assert nb.port == 10\n\n    assert isinstance(nb.url(), str)\n    assert str(nb) == nb.url()\n\n    with pytest.raises(NotImplementedError):\n        # Each sub-module is that inherits this as a parent is required to\n        # over-ride this function. So direct calls to this throws a not\n        # implemented error intentionally\n        nb.send(\"test message\")\n\n    # Throttle overrides..\n    nb = NotifyBase()\n    nb.request_rate_per_sec = 0.0\n    start_time = default_timer()\n    nb.throttle()\n    elapsed = default_timer() - start_time\n    # Should be a very fast response time since we set it to zero but we'll\n    # check for less then 500 to be fair as some testing systems may be slower\n    # then other\n    assert elapsed < 0.5\n\n    # Concurrent calls should achieve the same response\n    start_time = default_timer()\n    nb.throttle()\n    elapsed = default_timer() - start_time\n    assert elapsed < 0.5\n\n    nb = NotifyBase()\n    nb.request_rate_per_sec = 1.0\n\n    # Set our time to now\n    start_time = default_timer()\n    nb.throttle()\n    elapsed = default_timer() - start_time\n    # A first call to throttle (Without telling it a time previously ran) does\n    # not block for any length of time; it just merely sets us up for\n    # concurrent calls to block\n    assert elapsed < 0.5\n\n    # Concurrent calls could take up to the rate_per_sec though...\n    start_time = default_timer()\n    nb.throttle(last_io=datetime.now())\n    elapsed = default_timer() - start_time\n    assert elapsed > 0.48 and elapsed < 1.5\n\n    nb = NotifyBase()\n    nb.request_rate_per_sec = 1.0\n\n    # Set our time to now\n    start_time = default_timer()\n    nb.throttle(last_io=datetime.now())\n    elapsed = default_timer() - start_time\n    # because we told it that we had already done a previous action (now)\n    # the throttle holds out until the right time has passed\n    assert elapsed > 0.48 and elapsed < 1.5\n\n    # Concurrent calls could take up to the rate_per_sec though...\n    start_time = default_timer()\n    nb.throttle(last_io=datetime.now())\n    elapsed = default_timer() - start_time\n    assert elapsed > 0.48 and elapsed < 1.5\n\n    nb = NotifyBase()\n    start_time = default_timer()\n    nb.request_rate_per_sec = 1.0\n    # Force a time in the past\n    nb.throttle(last_io=(datetime.now() - timedelta(seconds=20)))\n    elapsed = default_timer() - start_time\n    # Should be a very fast response time since we set it to zero but we'll\n    # check for less then 500 to be fair as some testing systems may be slower\n    # then other\n    assert elapsed < 0.5\n\n    # Force a throttle time\n    start_time = default_timer()\n    nb.throttle(wait=0.5)\n    elapsed = default_timer() - start_time\n    assert elapsed > 0.48 and elapsed < 1.5\n\n    # our NotifyBase wasn't initialized with an ImageSize so this will fail\n    assert nb.image_url(notify_type=NotifyType.INFO) is None\n    assert nb.image_path(notify_type=NotifyType.INFO) is None\n    assert nb.image_raw(notify_type=NotifyType.INFO) is None\n\n    # Color handling\n    assert nb.color(notify_type=\"invalid\") == \\\n        AppriseAsset.default_html_color\n    assert isinstance(\n        nb.color(notify_type=NotifyType.INFO, color_type=None), str\n    )\n    assert isinstance(\n        nb.color(notify_type=NotifyType.INFO, color_type=int), int\n    )\n    assert isinstance(\n        nb.color(notify_type=NotifyType.INFO, color_type=tuple), tuple\n    )\n\n    # Ascii Handling\n    assert nb.ascii(notify_type=\"invalid\") == \\\n        AppriseAsset.default_ascii_chars\n    assert nb.ascii(NotifyType.INFO) == \"[i]\"\n    assert nb.ascii(NotifyType.SUCCESS) == \"[+]\"\n    assert nb.ascii(NotifyType.WARNING) == \"[~]\"\n    assert nb.ascii(NotifyType.FAILURE) == \"[!]\"\n\n    # Create an object\n    nb = NotifyBase()\n    # Force an image size since the default doesn't have one\n    nb.image_size = NotifyImageSize.XY_256\n\n    # We'll get an object this time around\n    assert nb.image_url(notify_type=NotifyType.INFO) is not None\n    assert nb.image_path(notify_type=NotifyType.INFO) is not None\n    assert nb.image_raw(notify_type=NotifyType.INFO) is not None\n\n    # Static function testing\n    assert (\n        NotifyBase.escape_html(\"<content>'\\t \\n</content>\")\n        == \"&lt;content&gt;&apos;&emsp;&nbsp;\\n&lt;/content&gt;\"\n    )\n\n    assert (\n        NotifyBase.escape_html(\n            \"<content>'\\t \\n</content>\", convert_new_lines=True\n        )\n        == \"&lt;content&gt;&apos;&emsp;&nbsp;<br/>&lt;/content&gt;\"\n    )\n\n    # Test invalid data\n    assert NotifyBase.split_path(None) == []\n    assert NotifyBase.split_path(object()) == []\n    assert NotifyBase.split_path(42) == []\n\n    assert NotifyBase.split_path(\n        \"/path/?name=Dr%20Disrespect\", unquote=False\n    ) == [\n        \"path\",\n        \"?name=Dr%20Disrespect\",\n    ]\n\n    assert NotifyBase.split_path(\n        \"/path/?name=Dr%20Disrespect\", unquote=True\n    ) == [\n        \"path\",\n        \"?name=Dr Disrespect\",\n    ]\n\n    # a slash found inside the path, if escaped properly will not be broken\n    # by split_path while additional concatinated slashes are ignored\n    # FYI: %2F = /\n    assert NotifyBase.split_path(\n        \"/%2F///%2F%2F////%2F%2F%2F////\", unquote=True\n    ) == [\n        \"/\",\n        \"//\",\n        \"///\",\n    ]\n\n    # Test invalid data\n    assert NotifyBase.parse_list(None) == []\n    assert NotifyBase.parse_list(object()) == []\n    assert NotifyBase.parse_list(42) == []\n\n    result = NotifyBase.parse_list(\n        \",path,?name=Dr%20Disrespect\", unquote=False\n    )\n    assert isinstance(result, list)\n    assert len(result) == 2\n    assert \"path\" in result\n    assert \"?name=Dr%20Disrespect\" in result\n\n    result = NotifyBase.parse_list(\",path,?name=Dr%20Disrespect\", unquote=True)\n    assert isinstance(result, list)\n    assert len(result) == 2\n    assert \"path\" in result\n    assert \"?name=Dr Disrespect\" in result\n\n    # by parse_list while additional concatinated slashes are ignored\n    # FYI: %2F = /\n    # In this lit there are actually 4 entries, however parse_list\n    # eliminates duplicates in addition to unquoting content by default\n    result = NotifyBase.parse_list(\n        \",%2F,%2F%2F, , , ,%2F%2F%2F, %2F\", unquote=True\n    )\n    assert isinstance(result, list)\n    assert len(result) == 3\n    assert \"/\" in result\n    assert \"//\" in result\n    assert \"///\" in result\n\n    # Phone number parsing\n    assert NotifyBase.parse_phone_no(None) == []\n    assert NotifyBase.parse_phone_no(object()) == []\n    assert NotifyBase.parse_phone_no(42) == []\n\n    result = NotifyBase.parse_phone_no(\n        \"+1-800-123-1234,(800) 123-4567\", unquote=False\n    )\n    assert isinstance(result, list)\n    assert len(result) == 2\n    assert \"+1-800-123-1234\" in result\n    assert \"(800) 123-4567\" in result\n\n    # %2B == +\n    result = NotifyBase.parse_phone_no(\n        \"%2B1-800-123-1234,%2B1%20800%20123%204567\", unquote=True\n    )\n    assert isinstance(result, list)\n    assert len(result) == 2\n    assert \"+1-800-123-1234\" in result\n    assert \"+1 800 123 4567\" in result\n\n    # Give nothing, get nothing\n    assert NotifyBase.escape_html(\"\") == \"\"\n    assert NotifyBase.escape_html(None) == \"\"\n    assert NotifyBase.escape_html(object()) == \"\"\n\n    # Test quote\n    assert NotifyBase.unquote(\"%20\") == \" \"\n    assert NotifyBase.quote(\" \") == \"%20\"\n    assert NotifyBase.unquote(None) == \"\"\n    assert NotifyBase.quote(None) == \"\"\n\n\ndef test_notify_base_urls():\n    \"\"\"\n    API: NotifyBase() URLs\n\n    \"\"\"\n\n    # Test verify switch whih is used as part of the SSL Verification\n    # by default all SSL sites are verified unless this flag is set to\n    # something like 'No', 'False', 'Disabled', etc.  Boolean values are\n    # pretty forgiving.\n    results = NotifyBase.parse_url(\"https://localhost:8080/?verify=No\")\n    assert \"verify\" in results\n    assert results[\"verify\"] is False\n\n    results = NotifyBase.parse_url(\"https://localhost:8080/?verify=Yes\")\n    assert \"verify\" in results\n    assert results[\"verify\"] is True\n\n    # The default is to verify\n    results = NotifyBase.parse_url(\"https://localhost:8080\")\n    assert \"verify\" in results\n    assert results[\"verify\"] is True\n\n    # Password Handling\n\n    # pass keyword over-rides default password\n    results = NotifyBase.parse_url(\"https://user:pass@localhost\")\n    assert \"password\" in results\n    assert results[\"password\"] == \"pass\"\n\n    # pass keyword over-rides default password\n    results = NotifyBase.parse_url(\n        \"https://user:pass@localhost?pass=newpassword\"\n    )\n    assert \"password\" in results\n    assert results[\"password\"] == \"newpassword\"\n\n    # password keyword can also optionally be used\n    results = NotifyBase.parse_url(\n        \"https://user:pass@localhost?password=passwd\"\n    )\n    assert \"password\" in results\n    assert results[\"password\"] == \"passwd\"\n\n    # pass= override password=\n    # password keyword can also optionally be used\n    results = NotifyBase.parse_url(\n        \"https://user:pass@localhost?pass=pw1&password=pw2\"\n    )\n    assert \"password\" in results\n    assert results[\"password\"] == \"pw1\"\n\n    # Options\n    results = NotifyBase.parse_url(\"https://localhost?format=invalid\")\n    assert \"format\" not in results\n    results = NotifyBase.parse_url(\"https://localhost?format=text\")\n    assert \"format\" in results\n    assert results[\"format\"] == \"text\"\n    results = NotifyBase.parse_url(\"https://localhost?format=markdown\")\n    assert \"format\" in results\n    assert results[\"format\"] == \"markdown\"\n    results = NotifyBase.parse_url(\"https://localhost?format=html\")\n    assert \"format\" in results\n    assert results[\"format\"] == \"html\"\n\n    results = NotifyBase.parse_url(\"https://localhost?overflow=invalid\")\n    assert \"overflow\" not in results\n    results = NotifyBase.parse_url(\"https://localhost?overflow=upstream\")\n    assert \"overflow\" in results\n    assert results[\"overflow\"] == \"upstream\"\n    results = NotifyBase.parse_url(\"https://localhost?overflow=split\")\n    assert \"overflow\" in results\n    assert results[\"overflow\"] == \"split\"\n    results = NotifyBase.parse_url(\"https://localhost?overflow=truncate\")\n    assert \"overflow\" in results\n    assert results[\"overflow\"] == \"truncate\"\n\n    # User Handling\n\n    # user keyword over-rides default password\n    results = NotifyBase.parse_url(\"https://user:pass@localhost\")\n    assert \"user\" in results\n    assert results[\"user\"] == \"user\"\n\n    # user keyword over-rides default password\n    results = NotifyBase.parse_url(\"https://user:pass@localhost?user=newuser\")\n    assert \"user\" in results\n    assert results[\"user\"] == \"newuser\"\n\n    # Test invalid urls\n    assert NotifyBase.parse_url(\"https://:@/\") is None\n    assert NotifyBase.parse_url(\"http://:@\") is None\n    assert NotifyBase.parse_url(\"http://@\") is None\n    assert NotifyBase.parse_url(\"http:///\") is None\n    assert NotifyBase.parse_url(\"http://:test/\") is None\n    assert NotifyBase.parse_url(\"http://pass:test/\") is None\n"
  },
  {
    "path": "tests/test_persistent_store.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timedelta, timezone\nimport gzip\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport shutil\nimport sys\nimport time\nfrom unittest import mock\nimport zlib\n\nimport pytest\n\nfrom apprise import exception\nfrom apprise.asset import AppriseAsset\nfrom apprise.persistent_store import (\n    CacheJSONEncoder,\n    CacheObject,\n    PersistentStore,\n    PersistentStoreMode,\n)\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n\ndef test_persistent_storage_asset(tmpdir):\n    \"\"\"Tests the Apprise Asset Object when setting the Persistent Store.\"\"\"\n\n    asset = AppriseAsset(storage_path=str(tmpdir))\n    assert asset.storage_path == str(tmpdir)\n    assert asset.storage_mode is PersistentStoreMode.AUTO\n\n    # If there is no storage path, we're always set to memory\n    asset = AppriseAsset(\n        storage_path=None, storage_mode=PersistentStoreMode.MEMORY\n    )\n    assert asset.storage_path is None\n    assert asset.storage_mode is PersistentStoreMode.MEMORY\n\n\ndef test_persistent_storage_bad_mode(tmpdir):\n    \"\"\"Persistent Storage Bad Mode Testing.\"\"\"\n    # Create ourselves an attachment object set in Memory Mode only\n    with pytest.raises(AttributeError):\n        PersistentStore(\n            namespace=\"abc\", path=str(tmpdir), mode=\"invalid\"\n        )\n\n    with pytest.raises(AttributeError):\n        AppriseAsset(storage_mode=\"invalid\")\n\n\ndef test_disabled_persistent_storage(tmpdir):\n    \"\"\"Persistent Storage General Testing.\"\"\"\n    # Create ourselves an attachment object set in Memory Mode only\n    pc = PersistentStore(\n        namespace=\"abc\", path=str(tmpdir), mode=PersistentStoreMode.MEMORY\n    )\n    assert pc.read() is None\n    assert pc.read(\"mykey\") is None\n    with pytest.raises(AttributeError):\n        # Invalid key specified\n        pc.read(\"!invalid\")\n    assert pc.write(\"data\") is False\n    assert pc.get(\"key\") is None\n    assert pc.set(\"key\", \"value\")\n    assert pc.get(\"key\") == \"value\"\n\n    assert pc.set(\"key2\", \"value\")\n    pc.clear(\"key\", \"key-not-previously-set\")\n    assert pc.get(\"key2\") == \"value\"\n    assert pc.get(\"key\") is None\n\n    # Set it again\n    assert pc.set(\"key\", \"another-value\")\n    # Clears all\n    pc.clear()\n    assert pc.get(\"key2\") is None\n    assert pc.get(\"key\") is None\n    # A second call to clear on an already empty cache set\n    pc.clear()\n\n    # No dirty flag is set as ther is nothing to write to disk\n    pc.set(\"not-persistent\", \"value\", persistent=False)\n    del pc[\"not-persistent\"]\n    with pytest.raises(KeyError):\n        # Can't delete it twice\n        del pc[\"not-persistent\"]\n\n    # A Persistent key\n    pc.set(\"persistent\", \"value\")\n    # Removes it and sets/clears the dirty flag\n    del pc[\"persistent\"]\n\n    # After all of the above, nothing was done to the directory\n    assert len(os.listdir(str(tmpdir))) == 0\n\n    with pytest.raises(AttributeError):\n        # invalid persistent store specified\n        PersistentStore(namespace=\"abc\", path=str(tmpdir), mode=\"garbage\")\n\n\ndef test_persistent_storage_init(tmpdir):\n    \"\"\"Test storage initialization.\"\"\"\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\"\", path=str(tmpdir))\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=None, path=str(tmpdir))\n\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\"_\", path=str(tmpdir))\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\".\", path=str(tmpdir))\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\"-\", path=str(tmpdir))\n\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\"_abc\", path=str(tmpdir))\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\".abc\", path=str(tmpdir))\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\"-abc\", path=str(tmpdir))\n\n    with pytest.raises(AttributeError):\n        PersistentStore(namespace=\"%\", path=str(tmpdir))\n\n\ndef test_persistent_storage_general(tmpdir):\n    \"\"\"Persistent Storage General Testing.\"\"\"\n    namespace = \"abc\"\n    # Create ourselves an attachment object\n    pc = PersistentStore()\n\n    # Default mode when a path is not provided\n    assert pc.mode == PersistentStoreMode.MEMORY\n\n    assert pc.size() == 0\n    assert pc.files() == []\n    assert pc.files(exclude=True, lazy=False) == []\n    assert pc.files(exclude=False, lazy=False) == []\n    pc.set(\"key\", \"value\")\n    # There is no disk size utilized\n    assert pc.size() == 0\n    assert pc.files(exclude=True, lazy=False) == []\n    assert pc.files(exclude=False, lazy=False) == []\n\n    # Create ourselves an attachment object\n    pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n\n    # Default mode when a path is provided\n    assert pc.mode == PersistentStoreMode.AUTO\n\n    # Get our path associated with our Persistent Store\n    assert pc.path == os.path.join(str(tmpdir), \"abc\")\n\n    # Expiry testing\n    assert pc.set(\"key\", \"value\", datetime.now() + timedelta(hours=1))\n    # i min in the future\n    assert pc.set(\"key\", \"value\", 60)\n\n    with pytest.raises(AttributeError):\n        assert pc.set(\"key\", \"value\", \"invalid\")\n\n    pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n\n    # Our key is still valid and we load it from disk\n    assert pc.get(\"key\") == \"value\"\n    assert pc[\"key\"] == \"value\"\n\n    pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n    assert pc.keys()\n    # Second call after already initialized skips over initialization\n    assert pc.keys()\n\n    pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n\n    with pytest.raises(KeyError):\n        # The below\n        pc[\"unassigned_key\"]\n\n\ndef test_persistent_storage_auto_mode(tmpdir):\n    \"\"\"Persistent Storage Auto Write Testing.\"\"\"\n    namespace = \"abc\"\n    # Create ourselves an attachment object\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.AUTO\n    )\n\n    pc.write(b\"test\")\n    with mock.patch(\"os.unlink\", side_effect=FileNotFoundError()):\n        assert pc.delete(all=True) is True\n\n    # Create a temporary file we can delete\n    with open(os.path.join(pc.path, pc.temp_dir, \"test.file\"), \"wb\") as fd:\n        fd.write(b\"data\")\n\n    # Delete just the temporary files\n    assert pc.delete(temp=True) is True\n\n    # Delete just the temporary files\n    # Create a cache entry and delete it\n    assert pc.set(\"key\", \"value\") is True\n    pc.write(b\"test\")\n    assert pc.delete(cache=True) is True\n    # Verify our data entry wasn't removed\n    assert pc.read() == b\"test\"\n    # But our cache was\n    assert pc.get(\"key\") is None\n\n    # A reverse of the above... create a cache an data variable and\n    # Clear the data; make sure our cache is still there\n    assert pc.set(\"key\", \"value\") is True\n    assert pc.write(b\"test\", key=\"iokey\") is True\n    assert pc.delete(\"iokey\") is True\n    assert pc.get(\"key\") == \"value\"\n    assert pc.read(\"iokey\") is None\n\n\ndef test_persistent_storage_flush_mode(tmpdir):\n    \"\"\"Persistent Storage Forced Write Testing.\"\"\"\n    namespace = \"abc\"\n    # Create ourselves an attachment object\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # Reference path\n    path = os.path.join(str(tmpdir), namespace)\n\n    assert pc.size() == 0\n    assert list(pc.files()) == []\n\n    # Key is not set yet\n    assert pc.get(\"key\") is None\n    assert len(pc.keys()) == 0\n    assert \"key\" not in pc\n\n    # Verify our data is set\n    assert pc.set(\"key\", \"value\")\n    assert len(pc.keys()) == 1\n    assert \"key\" in list(pc.keys())\n\n    assert pc.size() > 0\n    assert len(pc.files()) == 1\n\n    # Second call uses Lazy cache\n    # Just our cache file\n    assert len(pc.files()) == 1\n\n    # Setting the same value again uses a lazy mode and\n    # bypasses all of the write overhead\n    assert pc.set(\"key\", \"value\")\n\n    path_content = os.listdir(path)\n    # var, cache.psdata, and tmp\n    assert len(path_content) == 3\n\n    # Assignments (causes another disk write)\n    pc[\"key\"] = \"value2\"\n\n    # Setting the same value and explictly marking the field as not being\n    # perisistent\n    pc.set(\"key-xx\", \"abc123\", persistent=False)\n    # Changing it's value doesn't alter the persistent flag\n    pc[\"key-xx\"] = \"def678\"\n    # Setting it twice\n    pc[\"key-xx\"] = \"def678\"\n\n    # Our retrievals\n    assert pc[\"key-xx\"] == \"def678\"\n    assert pc.get(\"key-xx\") == \"def678\"\n\n    # But on the destruction of our object, it is not available again\n    del pc\n    # Create ourselves an attachment object\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    assert pc.get(\"key-xx\") is None\n    with pytest.raises(KeyError):\n        pc[\"key-xx\"]\n\n    # Now our key is set\n    assert \"key\" in pc\n    assert pc.get(\"key\") == \"value2\"\n\n    # A directory was created identified by the namespace\n    assert len(os.listdir(str(tmpdir))) == 1\n    assert namespace in os.listdir(str(tmpdir))\n\n    path_content = os.listdir(path)\n    assert len(path_content) == 4\n\n    # Another write doesn't change the file count\n    pc[\"key\"] = \"value3\"\n    path_content = os.listdir(path)\n    assert len(path_content) == 4\n\n    # Our temporary directory used for all file handling in this namespace\n    assert pc.temp_dir in path_content\n    # Our cache file\n    assert os.path.basename(pc.cache_file) in path_content\n\n    path = os.path.join(pc.path, pc.temp_dir)\n    path_content = os.listdir(path)\n\n    # We always do our best to clean any temporary files up\n    assert len(path_content) == 0\n\n    # Destroy our object\n    del pc\n\n    # Re-initialize it\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # Our key is persistent and available right away\n    assert pc.get(\"key\") == \"value3\"\n    assert \"key\" in pc\n\n    # Remove our item\n    del pc[\"key\"]\n    assert pc.size() == 0\n    assert \"key\" not in pc\n\n    assert pc.write(\"data\") is True\n    assert pc.read() == b\"data\"\n    assert pc.write(b\"data\") is True\n    assert pc.read() == b\"data\"\n\n    assert pc.read(\"default\") == b\"data\"\n    assert pc.write(\"data2\", key=\"mykey\") is True\n    assert pc.read(\"mykey\") == b\"data2\"\n\n    # We can selectively delete our key\n    assert pc.delete(\"mykey\")\n    assert pc.read(\"mykey\") is None\n    # Other keys are not touched\n    assert pc.read(\"default\") == b\"data\"\n    assert pc.read() == b\"data\"\n    # Full purge\n    assert pc.delete()\n    assert pc.read(\"mykey\") is None\n    assert pc.read() is None\n\n    # Practice with files\n    with open(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"), \"rb\") as fd:\n        assert pc.write(fd, key=\"mykey\", compress=False) is True\n\n        # Read our content back\n        fd.seek(0)\n        assert pc.read(\"mykey\", compress=False) == fd.read()\n\n    with open(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"), \"rb\") as fd:\n        assert pc.write(fd, key=\"mykey\", compress=True) is True\n\n        # Read our content back; content will be compressed\n        fd.seek(0)\n        assert pc.read(\"mykey\", compress=True) == fd.read()\n\n    class Foobar:\n        def read(*args, **kwargs):\n            return 42\n\n    foobar = Foobar()\n    # read() returns a non string/bin\n    with pytest.raises(exception.AppriseDiskIOError):\n        pc.write(foobar, key=\"foobar\", compress=True)\n    assert pc.read(\"foobar\") is None\n\n    class Foobar:\n        def read(*args, **kwargs):\n            return \"good\"\n\n    foobar = Foobar()\n    # read() returns a string so the below write works\n    assert pc.write(foobar, key=\"foobar\", compress=True)\n    assert pc.read(\"foobar\") == b\"good\"\n    pc.delete()\n\n    class Foobar:\n        def read(*args, **kwargs):\n            # Throw an exception\n            raise TypeError()\n\n    foobar = Foobar()\n    # read() returns a non string/bin\n    with pytest.raises(exception.AppriseDiskIOError):\n        pc.write(foobar, key=\"foobar\", compress=True)\n    assert pc.read(\"foobar\") is None\n\n    # Set our max_file_size\n    prev_max_file_size = pc.max_file_size\n    pc.max_file_size = 1\n    assert pc.delete()\n\n    assert pc.write(\"data\") is False\n    assert pc.read() is None\n\n    # Restore setting\n    pc.max_file_size = prev_max_file_size\n\n    # Reset\n    pc.delete()\n\n    assert pc.write(\"data\")\n    # Corrupt our data\n    data = pc.read(compress=False)[:20] + pc.read(compress=False)[:10]\n    pc.write(data, compress=False)\n\n    # Now we'll get an exception reading back the corrupted data\n    assert pc.read() is None\n\n    # Keep in mind though the data is still there; operator should write\n    # and read the way they expect to and things will work out fine\n    # This test just proves that Apprise Peresistent storage still\n    # gracefully handles bad data\n    assert pc.read(compress=False) == data\n\n    # No key exists also returns None\n    assert pc.read(\"no-key-exists\") is None\n\n    pc.write(b\"test\")\n    pc[\"key\"] = \"value\"\n    with mock.patch(\"os.unlink\", side_effect=FileNotFoundError()):\n        assert pc.delete(all=True) is True\n    with mock.patch(\"os.unlink\", side_effect=OSError()):\n        assert pc.delete(all=True) is False\n\n    # Create a temporary file we can delete\n    tmp_file = os.path.join(pc.path, pc.temp_dir, \"test.file\")\n    with open(tmp_file, \"wb\") as fd:\n        fd.write(b\"data\")\n\n    assert pc.set(\"key\", \"value\") is True\n    assert pc.write(b\"test\", key=\"iokey\") is True\n    # Delete just the temporary files\n    assert pc.delete(temp=True) is True\n    assert os.path.exists(tmp_file) is False\n    # our other entries are untouched\n    assert pc.get(\"key\") == \"value\"\n    assert pc.read(\"iokey\") == b\"test\"\n\n    # Delete just the temporary files\n    # Create a cache entry and delete it\n    assert pc.set(\"key\", \"value\") is True\n    assert pc.write(b\"test\") is True\n    assert pc.delete(cache=True) is True\n    # Verify our data entry wasn't removed\n    assert pc.read() == b\"test\"\n    # But our cache was\n    assert pc.get(\"key\") is None\n\n    # A reverse of the above... create a cache an data variable and\n    # Clear the data; make sure our cache is still there\n    assert pc.set(\"key\", \"value\") is True\n    assert pc.write(b\"test\", key=\"iokey\") is True\n    assert pc.delete(\"iokey\") is True\n    assert pc.get(\"key\") == \"value\"\n    assert pc.read(\"iokey\") is None\n\n    # Create some custom files\n    cust1_file = os.path.join(pc.path, \"test.file\")\n    cust2_file = os.path.join(pc.path, pc.data_dir, \"test.file\")\n    with open(cust1_file, \"wb\") as fd:\n        fd.write(b\"data\")\n    with open(cust2_file, \"wb\") as fd:\n        fd.write(b\"data\")\n\n    # Even after a full flush our files will exist\n    assert pc.delete()\n    assert os.path.exists(cust1_file) is True\n    assert os.path.exists(cust2_file) is True\n\n    # However, if we turn off validate, we do a full sweep because these\n    # unknown files are lingering in our directory space\n    assert pc.delete(validate=False)\n    assert os.path.exists(cust1_file) is False\n    assert os.path.exists(cust2_file) is False\n\n    pc[\"key\"] = \"value\"\n    pc[\"key2\"] = \"value2\"\n    assert \"key\" in pc\n    assert \"key2\" in pc\n    pc.clear(\"key\")\n    assert \"key\" not in pc\n    assert \"key2\" in pc\n\n    # Set expired content\n    pc.set(\n        \"expired\",\n        \"expired-content\",\n        expires=datetime.now() - timedelta(days=1),\n    )\n\n    # It's actually there... but it's expired so our persistent\n    # storage is behaving as it should\n    assert \"expired\" not in pc\n    assert pc.get(\"expired\") is None\n    # Prune our content\n    pc.prune()\n\n\ndef test_persistent_storage_corruption_handling(tmpdir):\n    \"\"\"Test corrupting handling of storage.\"\"\"\n\n    # Namespace\n    namespace = \"def456\"\n\n    # Initialize it\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    cache_file = pc.cache_file\n    assert not os.path.isfile(cache_file)\n\n    # Store our key\n    pc[\"mykey\"] = 42\n    assert os.path.isfile(cache_file)\n\n    with gzip.open(cache_file, \"rb\") as f:\n        # Read our content from disk\n        json.loads(f.read().decode(\"utf-8\"))\n\n    # Remove object\n    del pc\n\n    # Corrupt the file\n    with open(cache_file, \"wb\") as f:\n        f.write(b\"{\")\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # File is corrupted\n    assert \"mykey\" not in pc\n    pc[\"mykey\"] = 42\n    del pc\n\n    # File is corrected now\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    assert \"mykey\" in pc\n\n    # Corrupt the file again\n    with gzip.open(cache_file, \"wb\") as f:\n        # Bad JSON File\n        f.write(b\"{\")\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # File is corrupted\n    assert \"mykey\" not in pc\n    pc[\"mykey\"] = 42\n    del pc\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # Test our force flush\n    assert pc.flush(force=True) is True\n    # double call\n    assert pc.flush(force=True) is True\n\n    # Zlib error handling as well during open\n    with (\n        mock.patch(\"gzip.open\", side_effect=OSError()),\n        pytest.raises(KeyError),\n    ):\n        pc[\"mykey\"] = 43\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # Zlib error handling as well during open\n    with mock.patch(\"gzip.open\", side_effect=OSError()):\n        # No keys can be returned\n        assert not pc.keys()\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    with (\n        mock.patch(\"json.loads\", side_effect=TypeError()),\n        mock.patch(\"os.unlink\", side_effect=FileNotFoundError()),\n        pytest.raises(KeyError),\n    ):\n        pc[\"mykey\"] = 44\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    with (\n        mock.patch(\"json.loads\", side_effect=TypeError()),\n        mock.patch(\"os.unlink\", side_effect=OSError()),\n        pytest.raises(KeyError),\n    ):\n        pc[\"mykey\"] = 45\n\n    pc[\"my-new-key\"] = 43\n    with mock.patch(\"gzip.open\", side_effect=OSError()):\n        # We will fail to flush our content to disk\n        assert pc.flush(force=True) is False\n\n    with mock.patch(\"json.dumps\", side_effect=TypeError()):\n        # We will fail to flush our content to disk\n        assert pc.flush(force=True) is False\n\n    with mock.patch(\"os.makedirs\", side_effect=OSError()):\n        pc = PersistentStore(\n            namespace=namespace,\n            path=str(tmpdir),\n            mode=PersistentStoreMode.FLUSH,\n        )\n\n        # Directory initialization failed so we fall back to memory mode\n        assert pc.mode == PersistentStoreMode.MEMORY\n\n    # Handle file updates\n    pc = PersistentStore(\n        namespace=\"file-time-refresh\",\n        path=str(tmpdir),\n        mode=PersistentStoreMode.AUTO,\n    )\n\n    pc[\"test\"] = \"abcd\"\n    assert pc.write(b\"data\", key=\"abcd\") is True\n    assert pc.read(\"abcd\", expires=True) == b\"data\"\n    assert pc.write(b\"data2\", key=\"defg\") is True\n    assert pc.read(\"defg\", expires=False) == b\"data2\"\n    assert pc.write(b\"data3\", key=\"hijk\") is True\n    assert pc.read(\"hijk\", expires=False) == b\"data3\"\n    assert pc[\"test\"] == \"abcd\"\n\n    with mock.patch(\"os.utime\", side_effect=(OSError(), FileNotFoundError())):\n        pc.flush()\n\n    # directory initialization okay\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    assert \"mykey\" not in pc\n    pc[\"mykey\"] = 42\n    del pc\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n    assert \"mykey\" in pc\n\n    # Remove the last entry\n    del pc[\"mykey\"]\n    with (\n        mock.patch(\"os.rename\", side_effect=OSError()),\n        mock.patch(\"os.unlink\", side_effect=OSError()),\n    ):\n        assert not pc.flush(force=True)\n\n    # Create another entry\n    pc[\"mykey\"] = 42\n    with mock.patch(\"tempfile.NamedTemporaryFile\", side_effect=OSError()):\n        assert not pc.flush(force=True)\n\n        # Temporary file cleanup failure\n        with mock.patch(\n            \"tempfile._TemporaryFileWrapper.close\", side_effect=OSError()\n        ):\n            assert not pc.flush(force=True)\n\n    # Create another entry\n    pc[\"mykey\"] = 43\n    mock_ntf = mock.MagicMock()\n    mock_ntf.name = os.path.join(tmpdir, \"file\")\n\n    #\n    # Recursion loop checking\n    #\n    with mock.patch(\n        \"tempfile.NamedTemporaryFile\",\n        side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf],\n    ):\n        # No way to have recursion loop\n        assert not pc.flush(force=True, _recovery=True)\n\n    with mock.patch(\n        \"tempfile.NamedTemporaryFile\",\n        side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf],\n    ):\n        # No way to have recursion loop\n        assert not pc.flush(force=False, _recovery=True)\n\n    with mock.patch(\n        \"tempfile.NamedTemporaryFile\",\n        side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf],\n    ):\n        # No way to have recursion loop\n        assert not pc.flush(force=False, _recovery=False)\n\n    with mock.patch(\n        \"tempfile.NamedTemporaryFile\",\n        side_effect=[FileNotFoundError(), FileNotFoundError(), mock_ntf],\n    ):\n        # No way to have recursion loop\n        assert not pc.flush(force=True, _recovery=False)\n\n    with (\n        mock.patch(\n            \"tempfile._TemporaryFileWrapper.close\",\n            side_effect=(OSError(), None),\n        ),\n        mock.patch(\"os.unlink\", side_effect=(OSError())),\n    ):\n        assert not pc.flush(force=True)\n\n    with mock.patch(\n        \"tempfile._TemporaryFileWrapper.close\", side_effect=OSError()\n    ):\n        assert not pc.flush(force=True)\n\n    with (\n        mock.patch(\n            \"tempfile._TemporaryFileWrapper.close\",\n            side_effect=(OSError(), None),\n        ),\n        mock.patch(\"os.unlink\", side_effect=OSError()),\n    ):\n        assert not pc.flush(force=True)\n\n    with (\n        mock.patch(\n            \"tempfile._TemporaryFileWrapper.close\",\n            side_effect=(OSError(), None),\n        ),\n        mock.patch(\"os.unlink\", side_effect=FileNotFoundError()),\n    ):\n        assert not pc.flush(force=True)\n\n    del pc\n\n    # directory initialization okay\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # Allows us to play with encoding errors\n    pc.encoding = \"ascii\"\n\n    # Handle write() calls\n    with mock.patch(\"os.stat\", side_effect=OSError()):\n        # We fail to fetch the filesize of our old file causing us to fail\n        assert pc.write(\"abcd\") is False\n\n    # ボールト translates to vault (no bad word here) :)\n    data = \"ボールト\"\n\n    # We'll have encoding issues\n    assert pc.write(data) is False\n\n    with mock.patch(\"gzip.open\", side_effect=FileNotFoundError()):\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n\n        # recovery mode will kick in and even it will fail\n        assert pc.write(b\"key\") is False\n\n    with mock.patch(\"gzip.open\", side_effect=OSError()):\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n\n        # Falls to default\n        assert pc.get(\"key\") is None\n\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n        with pytest.raises(KeyError):\n            pc[\"key\"] = \"value\"\n\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n        with pytest.raises(KeyError):\n            pc[\"key\"]\n\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n        with pytest.raises(KeyError):\n            del pc[\"key\"]\n\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n        # Fails to set key\n        assert pc.set(\"key\", \"value\") is False\n\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n        # Fails to clear\n        assert pc.clear() is False\n\n        pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n        # Fails to prune\n        assert pc.prune() is False\n\n    # Set some expired content\n    pc.set(\n        \"key\",\n        \"value\",\n        persistent=False,\n        expires=datetime.now() - timedelta(days=1),\n    )\n    pc.set(\n        \"key2\",\n        \"value2\",\n        persistent=True,\n        expires=datetime.now() - timedelta(days=1),\n    )\n\n    # Set some un-expired content\n    pc.set(\"key3\", \"value3\", persistent=True)\n    pc.set(\"key4\", \"value4\", persistent=False)\n    assert pc.prune() is True\n\n    # Second call has no change made\n    assert pc.prune() is False\n\n    # Reset\n    pc.delete()\n\n    # directory initialization okay\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # Write some content that expires almost immediately\n    pc.set(\n        \"key1\",\n        \"value\",\n        persistent=True,\n        expires=datetime.now() + timedelta(seconds=1),\n    )\n    pc.set(\n        \"key2\",\n        \"value\",\n        persistent=True,\n        expires=datetime.now() + timedelta(seconds=1),\n    )\n    pc.set(\n        \"key3\",\n        \"value\",\n        persistent=True,\n        expires=datetime.now() + timedelta(seconds=1),\n    )\n    pc.flush()\n\n    # Wait out our expiry\n    time.sleep(1.3)\n\n    # now initialize our storage again\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # This triggers our __load_cache() which reads in a value\n    # determined to have already been expired\n    assert \"key1\" not in pc\n    assert \"key2\" not in pc\n    assert \"key3\" not in pc\n\n    # Sweep\n    pc.delete()\n    pc.set(\"key\", \"value\")\n    pc.set(\"key2\", \"value2\")\n    pc.write(\"more-content\")\n    # Flush our content to disk\n    pc.flush()\n\n    # Ideally we'd use os.stat below, but it is called inside a list\n    # comprehension block and mock doesn't appear to throw the exception\n    # there.  So this is a bit of a cheat, but it works\n    with mock.patch(\"builtins.sum\", side_effect=OSError()):\n        assert pc.size(exclude=True, lazy=False) == 0\n        assert pc.size(exclude=False, lazy=False) == 0\n\n    pc = PersistentStore(namespace=namespace, path=str(tmpdir))\n    with mock.patch(\"glob.glob\", side_effect=OSError()):\n        assert pc.files(exclude=True, lazy=False) == []\n        assert pc.files(exclude=False, lazy=False) == []\n\n    pc = PersistentStore(\n        namespace=namespace, path=str(tmpdir), mode=PersistentStoreMode.FLUSH\n    )\n\n    # Causes an initialization\n    pc[\"abc\"] = 1\n    with mock.patch(\"os.unlink\", side_effect=OSError()):\n        # Now we can't set data\n        with pytest.raises(KeyError):\n            pc[\"new-key\"] = \"value\"\n        # However keys that alrady exist don't get caught in check\n        # and therefore won't throw\n        pc[\"abc\"] = \"value\"\n\n    #\n    # Handles flush() when the queue is empty\n    #\n    pc.clear()\n    with mock.patch(\"os.unlink\", side_effect=OSError()):\n        # We can't remove backup cache file\n        assert pc.flush(force=True) is False\n\n    with mock.patch(\"os.unlink\", side_effect=FileNotFoundError()):\n        # FileNotFound is not an issue\n        assert pc.flush(force=True) is True\n\n    with mock.patch(\"os.rename\", side_effect=OSError()):\n        # We can't create a backup\n        assert pc.flush(force=True) is False\n\n    with mock.patch(\"os.rename\", side_effect=FileNotFoundError()):\n        # FileNotFound is not an issue\n        assert pc.flush(force=True) is True\n\n    # Flush any previous cache and data\n    pc.delete()\n\n    #\n    # Handles flush() cases where is data to write\n    #\n\n    # Create a key\n    pc.set(\"abc\", \"a-test-value\")\n    with mock.patch(\"os.unlink\", side_effect=(OSError(), None)):\n        # We failed to move our content in place\n        assert pc.flush(force=True) is False\n\n    with mock.patch(\"os.unlink\", side_effect=(OSError(), FileNotFoundError())):\n        # We failed to move our content in place\n        assert pc.flush(force=True) is False\n\n    with mock.patch(\"os.unlink\", side_effect=(OSError(), OSError())):\n        # We failed to move our content in place\n        assert pc.flush(force=True) is False\n\n\ndef test_persistent_custom_io(tmpdir):\n    \"\"\"Test reading and writing custom files.\"\"\"\n\n    # Initialize it for memory only\n    pc = PersistentStore(path=str(tmpdir))\n\n    with pytest.raises(AttributeError):\n        pc.open(\"!invalid#-Key\")\n\n    # We can't open the file as it does not exist\n    with pytest.raises(FileNotFoundError):\n        pc.open(\"valid-key\")\n\n    with pytest.raises(AttributeError):\n        # Bad data\n        pc.open(1234)\n\n    with pytest.raises(FileNotFoundError), pc.open(\"key\") as fd:\n        pass\n\n    # Also can be caught using Apprise Exception Handling\n    with pytest.raises(exception.AppriseFileNotFound), pc.open(\"key\") as fd:\n        pass\n\n    # Write some valid data\n    with pc.open(\"new-key\", \"wb\") as fd:\n        fd.write(b\"data\")\n\n    with mock.patch(\n        \"builtins.open\",\n        new_callable=mock.mock_open,\n        read_data=\"mocked file content\",\n    ) as mock_file:\n        mock_file.side_effect = OSError\n        with (\n            pytest.raises(exception.AppriseDiskIOError),\n            pc.open(\"new-key\", compress=True) as fd,\n        ):\n            pass\n\n    # Again but with compression this time\n    with mock.patch(\n        \"gzip.open\",\n        new_callable=mock.mock_open,\n        read_data=\"mocked file content\",\n    ) as mock_file:\n        mock_file.side_effect = OSError\n        with (\n            pytest.raises(exception.AppriseDiskIOError),\n            pc.open(\"new-key\", compress=True) as fd,\n        ):\n            pass\n\n    # Zlib error handling as well during open\n    with mock.patch(\n        \"gzip.open\",\n        new_callable=mock.mock_open,\n        read_data=\"mocked file content\",\n    ) as mock_file:\n        mock_file.side_effect = zlib.error\n        with (\n            pytest.raises(exception.AppriseDiskIOError),\n            pc.open(\"new-key\", compress=True) as fd,\n        ):\n            pass\n\n    # Writing\n    with pytest.raises(AttributeError):\n        pc.write(1234)\n\n    with pytest.raises(AttributeError):\n        pc.write(None)\n\n    with pytest.raises(AttributeError):\n        pc.write(True)\n\n    pc = PersistentStore(str(tmpdir))\n    with pc.open(\"key\", \"wb\") as fd:\n        fd.write(b\"test\")\n        fd.close()\n\n    # Handle error capuring when failing to write to disk\n    with mock.patch(\n        \"gzip.open\",\n        new_callable=mock.mock_open,\n        read_data=\"mocked file content\",\n    ) as mock_file:\n        mock_file.side_effect = zlib.error\n\n        # We fail to write to disk\n        assert pc.write(b\"test\") is False\n\n        # We support other errors too\n        mock_file.side_effect = OSError\n        assert pc.write(b\"test\") is False\n\n    with pytest.raises(AttributeError):\n        pc.write(b\"data\", key=\"!invalid#-Key\")\n\n    pc.delete()\n    with mock.patch(\"os.unlink\", side_effect=OSError()):\n        # Write our data and the __move() will fail under the hood\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with mock.patch(\"os.rename\", side_effect=OSError()):\n        # Write our data and the __move() will fail under the hood\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with mock.patch(\"os.unlink\", side_effect=(OSError(), FileNotFoundError())):\n        # Write our data and the __move() will fail under the hood\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with mock.patch(\"os.unlink\", side_effect=(OSError(), None)):\n        # Write our data and the __move() will fail under the hood\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with mock.patch(\"os.unlink\", side_effect=(OSError(), OSError())):\n        # Write our data and the __move() will fail under the hood\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with mock.patch(\"os.rename\", side_effect=(None, OSError(), None)):\n        assert pc.write(b\"test\") is False\n\n    with mock.patch(\"os.rename\", side_effect=(None, OSError(), OSError())):\n        assert pc.write(b\"test\") is False\n\n    with mock.patch(\n        \"os.rename\", side_effect=(None, OSError(), FileNotFoundError())\n    ):\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with mock.patch(\"os.rename\", side_effect=(None, None, None, OSError())):\n        # not enough reason to fail\n        assert pc.write(b\"test\") is True\n\n    with (\n        mock.patch(\"os.stat\", side_effect=OSError()),\n        mock.patch(\"os.close\", side_effect=(None, OSError())),\n    ):\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with mock.patch(\n        \"tempfile._TemporaryFileWrapper.close\", side_effect=OSError()\n    ):\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with (\n        mock.patch(\n            \"tempfile._TemporaryFileWrapper.close\",\n            side_effect=(OSError(), None),\n        ),\n        mock.patch(\"os.unlink\", side_effect=OSError()),\n    ):\n        assert pc.write(b\"test\") is False\n\n    pc.delete()\n    with (\n        mock.patch(\n            \"tempfile._TemporaryFileWrapper.close\",\n            side_effect=(OSError(), None),\n        ),\n        mock.patch(\"os.unlink\", side_effect=FileNotFoundError()),\n    ):\n        assert pc.write(b\"test\") is False\n\n\ndef test_persistent_storage_cache_object(tmpdir):\n    \"\"\"General testing of a CacheObject.\"\"\"\n    # A cache object\n    c = CacheObject(123)\n\n    ref = datetime.now(tz=timezone.utc)\n    expires = ref + timedelta(days=1)\n    # Create a cache object that expires tomorrow\n    c = CacheObject(\"abcd\", expires=expires)\n    assert c.expires == expires\n    assert c.expires_sec > 86390.0 and c.expires_sec <= 86400.0\n    assert bool(c) is True\n    assert \"never\" not in str(c)\n    assert \"str:+:abcd\" in str(c)\n\n    #\n    # Testing CacheObject.set()\n    #\n    c.set(123)\n    assert \"never\" not in str(c)\n    assert \"int:+:123\" in str(c)\n    hash_value = c.hash()\n    assert isinstance(hash_value, str)\n\n    c.set(124)\n    assert \"never\" not in str(c)\n    assert \"int:+:124\" in str(c)\n    assert c.hash() != hash_value\n\n    c.set(123)\n    # sha is the same again if we set the value back\n    assert c.hash() == hash_value\n\n    c.set(124)\n    assert isinstance(c.hash(), str)\n    assert c.value == 124\n    assert bool(c) is True\n    c.set(124, expires=False, persistent=False)\n    assert bool(c) is True\n    assert c.expires is None\n    assert c.expires_sec is None\n    c.set(124, expires=True)\n    # we're expired now\n    assert bool(c) is False\n\n    #\n    # Testing CacheObject equality (==)\n    #\n    a = CacheObject(\"abc\")\n    b = CacheObject(\"abc\")\n\n    assert a == b\n    assert a == \"abc\"\n    assert b == \"abc\"\n\n    # Equality is no longer a thing\n    b = CacheObject(\"abc\", 30)\n    assert a != b\n    # however we can look at the value inside\n    assert a == b.value\n\n    b = CacheObject(\"abc\", persistent=False)\n    a = CacheObject(\"abc\", persistent=True)\n    # Persistent flag matters\n    assert a != b\n    # however we can look at the value inside\n    assert a == b.value\n    b = CacheObject(\"abc\", persistent=True)\n    assert a == b\n\n    # Epoch\n    EPOCH = datetime(1970, 1, 1)\n\n    # test all of our supported types (also test time naive and aware times)\n    for entry in (\n        \"string\",\n        123,\n        1.2222,\n        datetime.now(),\n        datetime.now(tz=timezone.utc),\n        None,\n        False,\n        True,\n        b\"\\0\",\n    ):\n        # Create a cache object that expires tomorrow\n        c = CacheObject(entry, datetime.now() + timedelta(days=1))\n\n        # Verify our content hasn't expired\n        assert c\n\n        # Verify we can dump our object\n        result = json.loads(\n            json.dumps(c, separators=(\",\", \":\"), cls=CacheJSONEncoder)\n        )\n\n        # Instantiate our object\n        cc = CacheObject.instantiate(result)\n        assert cc.json() == c.json()\n\n    # Test our JSON Encoder against items we don't support\n    with pytest.raises(TypeError):\n        json.loads(\n            json.dumps(object(), separators=(\",\", \":\"), cls=CacheJSONEncoder)\n        )\n\n    assert CacheObject.instantiate(None) is None\n    assert CacheObject.instantiate({}) is None\n\n    # Bad data\n    assert (\n        CacheObject.instantiate({\"v\": 123, \"x\": datetime.now(), \"c\": \"int\"})\n        is None\n    )\n\n    # object type is not supported\n    assert (\n        CacheObject.instantiate({\n            \"v\": 123,\n            \"x\": (datetime.now() - EPOCH).total_seconds(),\n            \"c\": object,\n        })\n        is None\n    )\n\n    obj = CacheObject.instantiate(\n        {\"v\": 123, \"x\": (datetime.now() - EPOCH).total_seconds(), \"c\": \"int\"},\n        verify=False,\n    )\n    assert isinstance(obj, CacheObject)\n    assert obj.value == 123\n\n    # no HASH and verify is set to true; our checksum will fail\n    assert (\n        CacheObject.instantiate(\n            {\n                \"v\": 123,\n                \"x\": (datetime.now() - EPOCH).total_seconds(),\n                \"c\": \"int\",\n            },\n            verify=True,\n        )\n        is None\n    )\n\n    # We can't instantiate our object if the expiry value is bad\n    assert (\n        CacheObject.instantiate(\n            {\"v\": 123, \"x\": \"garbage\", \"c\": \"int\"}, verify=False\n        )\n        is None\n    )\n\n    # We need a valid hash sum too\n    assert (\n        CacheObject.instantiate(\n            {\n                \"v\": 123,\n                \"x\": (datetime.now() - EPOCH).total_seconds(),\n                \"c\": \"int\",\n                # Expecting a valid sha string\n                \"!\": 1.0,\n            },\n            verify=False,\n        )\n        is None\n    )\n\n    # Our Bytes Object with corruption\n    assert (\n        CacheObject.instantiate(\n            {\n                \"v\": \"garbage\",\n                \"x\": (datetime.now() - EPOCH).total_seconds(),\n                \"c\": \"bytes\",\n            },\n            verify=False,\n        )\n        is None\n    )\n\n    obj = CacheObject.instantiate(\n        {\n            \"v\": \"AA==\",\n            \"x\": (datetime.now() - EPOCH).total_seconds(),\n            \"c\": \"bytes\",\n        },\n        verify=False,\n    )\n    assert isinstance(obj, CacheObject)\n    assert obj.value == b\"\\0\"\n\n    # Test our datetime objects\n    obj = CacheObject.instantiate(\n        {\n            \"v\": \"2024-06-08T01:50:01.587267\",\n            \"x\": (datetime.now() - EPOCH).total_seconds(),\n            \"c\": \"datetime\",\n        },\n        verify=False,\n    )\n    assert isinstance(obj, CacheObject)\n    assert obj.value == datetime(2024, 6, 8, 1, 50, 1, 587267)\n\n    # A corrupt datetime object\n    assert (\n        CacheObject.instantiate(\n            {\n                \"v\": \"garbage\",\n                \"x\": (datetime.now() - EPOCH).total_seconds(),\n                \"c\": \"datetime\",\n            },\n            verify=False,\n        )\n        is None\n    )\n\n\n@pytest.mark.skipif(\n    sys.platform == \"win32\", reason=\"Unreliable results to be determined\"\n)\ndef test_persistent_storage_disk_prune(tmpdir):\n    \"\"\"General testing of a Persistent Store prune calls.\"\"\"\n\n    # Persistent Storage Initialization\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t01\", mode=PersistentStoreMode.FLUSH\n    )\n    # Store some data\n    assert pc.write(b\"data-t01\") is True\n    assert pc.set(\"key-t01\", \"value\")\n\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n    )\n    # Store some data\n    assert pc.write(b\"data-t02\") is True\n    assert pc.set(\"key-t02\", \"value\")\n\n    # purne anything older then 30s\n    results = PersistentStore.disk_prune(path=str(tmpdir), expires=30)\n    # Nothing is older then 30s right now\n    assert isinstance(results, dict)\n    assert \"t01\" in results\n    assert \"t02\" in results\n    assert len(results[\"t01\"]) == 0\n    assert len(results[\"t02\"]) == 0\n\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t01\", mode=PersistentStoreMode.FLUSH\n    )\n\n    # Nothing is pruned\n    assert pc.get(\"key-t01\") == \"value\"\n    assert pc.read() == b\"data-t01\"\n\n    # An expiry of zero gets everything\n    # Note: This test randomly fails in Microsoft Windows for unknown reasons\n    # When this is determined, this test can be opened back up\n    results = PersistentStore.disk_prune(path=str(tmpdir), expires=0)\n    # We match everything now\n    assert isinstance(results, dict)\n    assert \"t01\" in results\n    assert \"t02\" in results\n    assert len(results[\"t01\"]) == 2\n    assert len(results[\"t02\"]) == 2\n\n    # Content is still not removed however because no action was put in place\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n    )\n    # Nothing is pruned\n    assert pc.get(\"key-t02\") == \"value\"\n    assert pc.read() == b\"data-t02\"\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t01\", mode=PersistentStoreMode.FLUSH\n    )\n    # Nothing is pruned\n    assert pc.get(\"key-t01\") == \"value\"\n    assert pc.read() == b\"data-t01\"\n\n    with mock.patch(\"os.listdir\", side_effect=OSError()):\n        results = PersistentStore.disk_scan(\n            namespace=\"t01\", path=str(tmpdir), closest=True\n        )\n        assert isinstance(results, list)\n        assert len(results) == 0\n\n    with mock.patch(\"os.listdir\", side_effect=FileNotFoundError()):\n        results = PersistentStore.disk_scan(\n            namespace=\"t01\", path=str(tmpdir), closest=True\n        )\n        assert isinstance(results, list)\n        assert len(results) == 0\n\n        # Without closest flag\n        results = PersistentStore.disk_scan(\n            namespace=\"t01\", path=str(tmpdir), closest=False\n        )\n        assert isinstance(results, list)\n        assert len(results) == 0\n\n    # Now we'll filter on specific namespaces\n    results = PersistentStore.disk_prune(\n        namespace=\"notfound\", path=str(tmpdir), expires=0, action=True\n    )\n\n    # nothing matched, nothing found\n    assert isinstance(results, dict)\n    assert len(results) == 0\n\n    results = PersistentStore.disk_prune(\n        namespace=(\"t01\", \"invalid\", \"-garbag!\"),\n        path=str(tmpdir),\n        expires=0,\n        action=True,\n    )\n\n    # only t01 would be cleaned now\n    assert isinstance(results, dict)\n    assert len(results) == 1\n    assert len(results[\"t01\"]) == 2\n\n    # A second call will yield no results because the content has\n    # already been cleaned up\n    results = PersistentStore.disk_prune(\n        namespace=\"t01\", path=str(tmpdir), expires=0, action=True\n    )\n    assert isinstance(results, dict)\n    assert len(results) == 0\n\n    # t02 is still untouched\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n    )\n    # Nothing is pruned\n    assert pc.get(\"key-t02\") == \"value\"\n    assert pc.read() == b\"data-t02\"\n\n    # t01 of course... it's gone\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t01\", mode=PersistentStoreMode.FLUSH\n    )\n    # Nothing is pruned\n    assert pc.get(\"key-t01\") is None\n    assert pc.read() is None\n\n    with pytest.raises(AttributeError):\n        # provide garbage in namespace field and we're going to have a problem\n        PersistentStore.disk_prune(\n            namespace=object, path=str(tmpdir), expires=0, action=True\n        )\n\n    # Error Handling\n    with mock.patch(\"os.path.getmtime\", side_effect=FileNotFoundError()):\n        results = PersistentStore.disk_prune(\n            namespace=\"t02\", path=str(tmpdir), expires=0, action=True\n        )\n        assert isinstance(results, dict)\n        assert len(results) == 1\n        assert len(results[\"t02\"]) == 0\n\n        # no files were removed, so our data is still accessible\n        pc = PersistentStore(\n            path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n        )\n        # Nothing is pruned\n        assert pc.get(\"key-t02\") == \"value\"\n        assert pc.read() == b\"data-t02\"\n\n    with mock.patch(\"os.path.getmtime\", side_effect=OSError()):\n        results = PersistentStore.disk_prune(\n            namespace=\"t02\", path=str(tmpdir), expires=0, action=True\n        )\n        assert isinstance(results, dict)\n        assert len(results) == 1\n        assert len(results[\"t02\"]) == 0\n\n        # no files were removed, so our data is still accessible\n        pc = PersistentStore(\n            path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n        )\n        # Nothing is pruned\n        assert pc.get(\"key-t02\") == \"value\"\n        assert pc.read() == b\"data-t02\"\n\n    with mock.patch(\"os.unlink\", side_effect=FileNotFoundError()):\n        results = PersistentStore.disk_prune(\n            namespace=\"t02\", path=str(tmpdir), expires=0, action=True\n        )\n        assert isinstance(results, dict)\n        assert len(results) == 1\n        assert len(results[\"t02\"]) == 2\n\n        # no files were removed, so our data is still accessible\n        pc = PersistentStore(\n            path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n        )\n        # Nothing is pruned\n        assert pc.get(\"key-t02\") == \"value\"\n        assert pc.read() == b\"data-t02\"\n\n    with mock.patch(\"os.unlink\", side_effect=OSError()):\n        results = PersistentStore.disk_prune(\n            namespace=\"t02\", path=str(tmpdir), expires=0, action=True\n        )\n        assert isinstance(results, dict)\n        assert len(results) == 1\n        assert len(results[\"t02\"]) == 2\n\n        # no files were removed, so our data is still accessible\n        pc = PersistentStore(\n            path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n        )\n        # Nothing is pruned\n        assert pc.get(\"key-t02\") == \"value\"\n        assert pc.read() == b\"data-t02\"\n\n    with mock.patch(\"os.rmdir\", side_effect=OSError()):\n        results = PersistentStore.disk_prune(\n            namespace=\"t02\", path=str(tmpdir), expires=0, action=True\n        )\n        assert isinstance(results, dict)\n        assert len(results) == 1\n        assert len(results[\"t02\"]) == 2\n\n        # no files were removed, so our data is still accessible\n        pc = PersistentStore(\n            path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n        )\n        # Nothing is pruned\n        assert pc.get(\"key-t02\") is None\n        assert pc.read() is None\n\n\ndef test_persistent_storage_disk_changes(tmpdir):\n    \"\"\"General testing of a Persistent Store with underlining disk changes.\"\"\"\n\n    # Create a garbage file in place of where the namespace should be\n    tmpdir.join(\"t01\").write(\"0\" * 1024)\n\n    # Persistent Storage Initialization where namespace directory now is\n    # already occupied by a filename\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t01\", mode=PersistentStoreMode.FLUSH\n    )\n\n    # Store some data and note that it isn't possible\n    assert pc.write(b\"data-t01\") is False\n    # We actually fell back to memory mode:\n    assert pc.mode == PersistentStoreMode.MEMORY\n\n    # Set's work\n    assert pc.set(\"key-t01\", \"value\")\n\n    # But upon reinitializtion (enforcing memory mode check) we will not have\n    # the data available to us\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t01\", mode=PersistentStoreMode.FLUSH\n    )\n\n    assert pc.get(\"key-t01\") is None\n\n    #\n    # Test situation where the file structure changed after initialization\n    #\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n    )\n    # Our mode stuck as t02 initialized correctly\n    assert pc.mode == PersistentStoreMode.FLUSH\n    assert os.path.isdir(pc.path)\n\n    shutil.rmtree(pc.path)\n    assert not os.path.isdir(pc.path)\n    assert pc.set(\"key-t02\", \"value\")\n    # The directory got re-created\n    assert os.path.isdir(pc.path)\n\n    # Same test but flag set to AUTO\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.AUTO\n    )\n    # Our mode stuck as t02 initialized correctly\n    assert pc.mode == PersistentStoreMode.AUTO\n    assert os.path.isdir(pc.path)\n\n    shutil.rmtree(pc.path)\n    assert not os.path.isdir(pc.path)\n    assert pc.set(\"key-t02\", \"value\")\n    # The directory is not recreated because of auto; it will occur on save\n    assert not os.path.isdir(pc.path)\n    path = pc.path\n    del pc\n    # It exists now\n    assert os.path.isdir(path)\n\n    pc = PersistentStore(\n        path=str(tmpdir), namespace=\"t02\", mode=PersistentStoreMode.FLUSH\n    )\n    # Content was not lost\n    assert pc.get(\"key-t02\") == \"value\"\n\n    # We'll remove a sub directory of it this time\n    shutil.rmtree(os.path.join(pc.path, pc.temp_dir))\n\n    # We will still successfully write our data\n    assert pc.write(b\"data-t02\") is True\n    assert os.path.isdir(pc.path)\n\n    shutil.rmtree(pc.path)\n    assert not os.path.isdir(pc.path)\n    assert pc.set(\"key-t01\", \"value\")\n"
  },
  {
    "path": "tests/test_plugin_africas_talking.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.africas_talking import NotifyAfricasTalking\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"atalk://\",\n        {\n            # Instantiated but no auth, so no notification can happen\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"atalk://:@/\",\n        {\n            # invalid auth\n            \"instance\": TypeError\n        },\n    ),\n    (\n        \"atalk://user@^/\",\n        {\n            # invalid apikey\n            \"instance\": TypeError\n        },\n    ),\n    (\n        \"atalk://user@apikey/{}\".format(\"3\" * 5),\n        {\n            # invalid nubmer provided\n            \"instance\": NotifyAfricasTalking,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"atalk://user@apikey/123/{}/abcd/+{}\".format(\"3\" * 11, \"4\" * 11),\n        {\n            # includes a few invalid bits of info\n            \"instance\": NotifyAfricasTalking,\n            \"privacy_url\": \"atalk://user@a...y/33333333333/+44444444444\",\n        },\n    ),\n    (\n        \"atalk://user@apikey/+{}?batch=y\".format(\"4\" * 11),\n        {\n            \"instance\": NotifyAfricasTalking,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"atalk://user@a...y/+44444444444\",\n        },\n    ),\n    (\n        \"atalk://user@apikey/+{}?mode=invalid\".format(\"4\" * 11),\n        {\"instance\": TypeError},\n    ),\n    (\n        \"atalk://user@apikey/+{}?mode=s\".format(\"4\" * 11),\n        {\n            # S will match the sandbox\n            \"instance\": NotifyAfricasTalking,\n        },\n    ),\n    (\n        \"atalk://user@apikey/+{}?mode=PREM\".format(\"4\" * 11),\n        {\n            # PREM will match premium (not case sensitive)\n            \"instance\": NotifyAfricasTalking,\n        },\n    ),\n    (\n        \"atalk://{}?apikey=key&user=user&from=FROMUSER\".format(\"1\" * 11),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyAfricasTalking,\n        },\n    ),\n    (\n        \"atalk://_?user=user&to={},{}&key={}&from={}\".format(\n            \"1\" * 11, \"2\" * 11, \"b\" * 10, \"5\" * 13\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyAfricasTalking,\n        },\n    ),\n    (\n        \"atalk://user@apikey/{}/\".format(\"1\" * 11),\n        {\n            \"instance\": NotifyAfricasTalking,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"atalk://user@apikey/{}/\".format(\"1\" * 11),\n        {\n            \"instance\": NotifyAfricasTalking,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_atalk_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_atalk_edge_cases(mock_post):\n    \"\"\"NotifyAfricasTalking() Edge Cases.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    apikey = \"my-api-key\"\n    appuser = \"my-app-user\"\n    targets = [\n        \"+1(555) 123-1234\",\n        \"1555 5555555\",\n        # A garbage entry\n        \"12\",\n    ]\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"atalk://{}@{}/{}?batch=n\".format(appuser, apikey, \"/\".join(targets))\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # We know there are 2 (valid) targets\n    assert len(obj) == 2\n\n    # Test our call count\n    assert mock_post.call_count == 2\n\n    # Test\n    details = mock_post.call_args_list[0]\n    headers = details[1][\"headers\"]\n    assert headers[\"apiKey\"] == apikey\n    payload = details[1][\"data\"]\n    assert payload[\"username\"] == appuser\n    assert payload[\"from\"] == \"AFRICASTKNG\"\n    assert payload[\"to\"] == \"+15551231234\"\n    assert payload[\"message\"] == \"title\\r\\nbody\"\n\n    details = mock_post.call_args_list[1]\n    headers = details[1][\"headers\"]\n    assert headers[\"apiKey\"] == apikey\n    payload = details[1][\"data\"]\n    assert payload[\"username\"] == appuser\n    assert payload[\"from\"] == \"AFRICASTKNG\"\n    assert payload[\"to\"] == \"15555555555\"\n    assert payload[\"message\"] == \"title\\r\\nbody\"\n\n    # Verify our URL looks good\n    assert obj.url().startswith(\n        \"atalk://{}@{}/{}\".format(\n            appuser, apikey, \"/\".join([\"+15551231234\", \"15555555555\"])\n        )\n    )\n\n    assert \"batch=no\" in obj.url()\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # With our batch in place, our calculations are different\n    # Testing URL restructuring here as well where phone # is found\n    # in host\n    obj = Apprise.instantiate(\n        \"atalk://{}?user={}&apikey={}&batch=y&from=TEST\".format(\n            \"/\".join(targets), appuser, apikey\n        )\n    )\n\n    # 2 phones were loaded but counted as 1 due to batch flag\n    assert len(obj) == 1\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Test our call count (batched into 1)\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    headers = details[1][\"headers\"]\n    assert headers[\"apiKey\"] == apikey\n    payload = details[1][\"data\"]\n    assert payload[\"username\"] == appuser\n    assert payload[\"from\"] == \"TEST\"\n    assert payload[\"to\"] == \"+15551231234,15555555555\"\n    assert payload[\"message\"] == \"title\\r\\nbody\"\n"
  },
  {
    "path": "tests/test_plugin_apprise_api.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nfrom json import loads\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.apprise_api import NotifyAppriseAPI\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"apprise://\",\n        {\n            # invalid url (not complete)\n            \"instance\": None,\n        },\n    ),\n    # A a bad url\n    (\n        \"apprise://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # No token specified\n    (\n        \"apprise://localhost\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # invalid token\n    (\n        \"apprise://localhost/!\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No token specified (whitespace is trimmed)\n    (\n        \"apprise://localhost/%%20\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # A valid URL with Token\n    (\n        \"apprise://localhost/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprise://localhost/a...a/\",\n            \"force_debug\": True,\n        },\n    ),\n    # A valid URL with long Token\n    (\n        \"apprise://localhost/%s\" % (\"a\" * 128),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprise://localhost/a...a/\",\n        },\n    ),\n    # A valid URL with Token (using port)\n    (\n        \"apprise://localhost:8080/%s\" % (\"b\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprise://localhost:8080/b...b/\",\n        },\n    ),\n    # A secure (https://) reference\n    (\n        \"apprises://localhost/%s\" % (\"c\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprises://localhost/c...c/\",\n        },\n    ),\n    # Native URL suport (https)\n    (\n        \"https://example.com/path/notify/%s\" % (\"d\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprises://example.com/path/d...d/\",\n        },\n    ),\n    # Native URL suport (http)\n    (\n        \"http://example.com/notify/%s\" % (\"d\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprise://example.com/d...d/\",\n        },\n    ),\n    # support to= keyword\n    (\n        \"apprises://localhost/?to=%s\" % (\"e\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            \"privacy_url\": \"apprises://localhost/e...e/\",\n        },\n    ),\n    # support token= keyword (even when passed with to=, token over-rides)\n    (\n        \"apprise://localhost/?token={}&to={}\".format(\"f\" * 32, \"abcd\"),\n        {\n            \"instance\": NotifyAppriseAPI,\n            \"privacy_url\": \"apprise://localhost/f...f/\",\n        },\n    ),\n    # Test tags\n    (\n        \"apprise://localhost/?token={}&tags=admin,team\".format(\"abcd\"),\n        {\n            \"instance\": NotifyAppriseAPI,\n            \"privacy_url\": \"apprise://localhost/a...d/\",\n        },\n    ),\n    # Test Format string\n    (\n        \"apprise://user@localhost/mytoken0/?format=markdown\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            \"privacy_url\": \"apprise://user@localhost/m...0/\",\n        },\n    ),\n    (\n        \"apprise://user@localhost/mytoken1/\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            \"privacy_url\": \"apprise://user@localhost/m...1/\",\n        },\n    ),\n    (\n        \"apprise://localhost:8080/mytoken/\",\n        {\n            \"instance\": NotifyAppriseAPI,\n        },\n    ),\n    (\n        \"apprise://user:pass@localhost:8080/mytoken2/\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            \"privacy_url\": \"apprise://user:****@localhost:8080/m...2/\",\n        },\n    ),\n    (\n        \"apprises://localhost/mytoken/\",\n        {\n            \"instance\": NotifyAppriseAPI,\n        },\n    ),\n    (\n        \"apprises://user:pass@localhost/mytoken3/\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprises://user:****@localhost/m...3/\",\n        },\n    ),\n    (\n        \"apprises://localhost:8080/mytoken4/\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprises://localhost:8080/m...4/\",\n        },\n    ),\n    (\n        \"apprises://localhost:8080/abc123/?method=json\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprises://localhost:8080/a...3/\",\n        },\n    ),\n    (\n        \"apprises://localhost:8080/abc123/?method=form\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprises://localhost:8080/a...3/\",\n        },\n    ),\n    # Invalid method specified\n    (\n        \"apprises://localhost:8080/abc123/?method=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"apprises://user:password@localhost:8080/mytoken5/\",\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"apprises://user:****@localhost:8080/m...5/\",\n        },\n    ),\n    (\n        \"apprises://localhost:8080/path?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifyAppriseAPI,\n        },\n    ),\n    (\n        \"apprise://localhost/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"apprise://localhost/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"apprise://localhost/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyAppriseAPI,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_apprise_api_urls():\n    \"\"\"NotifyAppriseAPI() General Checks.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_notify_apprise_api_payload_check(mock_post):\n    \"\"\"NotifyAppriseAPI() payload checks\"\"\"\n\n    okay_response = requests.Request()\n\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    obj = Apprise.instantiate(\n        \"apprise://user@localhost/mytoken1/?method=form\"\n    )\n    assert isinstance(obj, NotifyAppriseAPI)\n\n    # Test URL with Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://localhost/notify/mytoken1\"\n    assert details[1][\"data\"] == {\n        \"title\": \"title\",\n        \"body\": \"body\",\n        \"type\": \"info\",\n        \"format\": \"text\",\n    }\n    assert \"X-Apprise-ID\" in details[1][\"headers\"]\n    assert details[1][\"headers\"].get(\"User-Agent\") == \"Apprise\"\n    assert details[1][\"headers\"].get(\"Accept\") == \"application/json\"\n    assert details[1][\"headers\"].get(\"X-Apprise-Recursion-Count\") == \"1\"\n\n    mock_post.reset_mock()\n\n    obj = Apprise.instantiate(\n        \"apprise://user@localhost/mytoken1/?method=json\"\n    )\n    assert isinstance(obj, NotifyAppriseAPI)\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://localhost/notify/mytoken1\"\n    data = loads(details[1][\"data\"])\n    assert \"attachments\" in data\n    assert isinstance(data[\"attachments\"], list)\n    assert len(data[\"attachments\"]) == 1\n\n    # Remove our attachment to make the next assert easier\n    del data[\"attachments\"]\n    assert data == {\n        \"title\": \"title\",\n        \"body\": \"body\",\n        \"type\": \"info\",\n        \"format\": \"text\",\n    }\n    assert \"X-Apprise-ID\" in details[1][\"headers\"]\n    assert details[1][\"headers\"].get(\"User-Agent\") == \"Apprise\"\n    assert details[1][\"headers\"].get(\"Accept\") == \"application/json\"\n    assert details[1][\"headers\"].get(\"X-Apprise-Recursion-Count\") == \"1\"\n\n\n@mock.patch(\"requests.post\")\ndef test_notify_apprise_api_attachments(mock_post):\n    \"\"\"NotifyAppriseAPI() Attachments.\"\"\"\n\n    okay_response = requests.Request()\n\n    for method in (\"json\", \"form\"):\n        okay_response.status_code = requests.codes.ok\n        okay_response.content = \"\"\n\n        # Assign our mock object our return value\n        mock_post.return_value = okay_response\n\n        obj = Apprise.instantiate(\n            f\"apprise://user@localhost/mytoken1/?method={method}\"\n        )\n        assert isinstance(obj, NotifyAppriseAPI)\n\n        # Test Valid Attachment\n        path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n        attach = AppriseAttachment(path)\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is True\n        )\n\n        # Test invalid attachment\n        path = os.path.join(\n            TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\"\n        )\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=path,\n            )\n            is False\n        )\n\n        # Test Valid Attachment (load 3)\n        path = (\n            os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n            os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n            os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        )\n        attach = AppriseAttachment(path)\n\n        # Return our good configuration\n        mock_post.side_effect = None\n        mock_post.return_value = okay_response\n        with mock.patch(\"builtins.open\", side_effect=OSError()):\n            # We can't send the message we can't open the attachment for\n            # reading\n            assert (\n                obj.notify(\n                    body=\"body\",\n                    title=\"title\",\n                    notify_type=NotifyType.INFO,\n                    attach=attach,\n                )\n                is False\n            )\n\n        with mock.patch(\"requests.post\", side_effect=OSError()):\n            # Attachment issue\n            assert (\n                obj.notify(\n                    body=\"body\",\n                    title=\"title\",\n                    notify_type=NotifyType.INFO,\n                    attach=attach,\n                )\n                is False\n            )\n\n        # test the handling of our batch modes\n        obj = Apprise.instantiate(\"apprise://user@localhost/mytoken1/\")\n        assert isinstance(obj, NotifyAppriseAPI)\n\n        # Now send an attachment normally without issues\n        mock_post.reset_mock()\n\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is True\n        )\n        assert mock_post.call_count == 1\n\n        details = mock_post.call_args_list[0]\n        assert details[0][0] == \"http://localhost/notify/mytoken1\"\n        assert obj.url(privacy=False).startswith(\n            \"apprise://user@localhost/mytoken1/\"\n        )\n\n        mock_post.reset_mock()\n"
  },
  {
    "path": "tests/test_plugin_aprs.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport socket\nfrom unittest import mock\n\nimport apprise\nfrom apprise.plugins.aprs import NotifyAprs\n\nlogging.disable(logging.CRITICAL)\n\n\n@mock.patch(\"socket.create_connection\")\ndef test_plugin_aprs_urls(mock_create_connection):\n    \"\"\"NotifyAprs() Apprise URLs.\"\"\"\n    # A socket object\n    sobj = mock.Mock()\n    sobj.return_value = 1\n    sobj.getpeername.return_value = (\"localhost\", 1234)\n    sobj.socket_close.return_value = None\n    sobj.setblocking.return_value = True\n    sobj.recv.return_value = \"ping\\npong pong DF1JSL-15 verified pong\".encode(\n        \"latin-1\"\n    )\n    sobj.sendall.return_value = True\n    sobj.settimeout.return_value = True\n\n    # Prepare Mock\n    mock_create_connection.return_value = sobj\n\n    # Test invalid URLs\n    assert apprise.Apprise.instantiate(\"aprs://\") is None\n    assert apprise.Apprise.instantiate(\"aprs://:@/\") is None\n\n    # No call-sign specified\n    assert apprise.Apprise.instantiate(\"aprs://DF1JSL-15:12345\") is None\n\n    # Garbage\n    assert NotifyAprs.parse_url(None) is None\n\n    # Valid call-sign but no password\n    assert apprise.Apprise.instantiate(\"aprs://DF1JSL-15:@DF1ABC\") is None\n    assert apprise.Apprise.instantiate(\"aprs://DF1JSL-15@DF1ABC\") is None\n    # Password of -1 not supported\n    assert apprise.Apprise.instantiate(\"aprs://DF1JSL-15:-1@DF1ABC\") is None\n    # Alpha Password not supported\n    assert apprise.Apprise.instantiate(\"aprs://DF1JSL-15:abcd@DF1ABC\") is None\n\n    # Valid instances\n    instance = apprise.Apprise.instantiate(\"aprs://DF1JSL-15:12345@DF1ABC\")\n    assert isinstance(instance, NotifyAprs)\n    assert instance.url(privacy=True).startswith(\n        \"aprs://DF1JSL-15:****@D...C?\"\n    )\n    assert instance.notify(\"test\") is True\n\n    # 1N3 callsigns\n    instance = apprise.Apprise.instantiate(\n        \"aprs://D1JSL-15:12345@D1ABC\"\n    )\n    assert isinstance(instance, NotifyAprs)\n\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC?delay=3.0\"\n    )\n    assert isinstance(instance, NotifyAprs)\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC?delay=2\"\n    )\n    assert isinstance(instance, NotifyAprs)\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC?delay=-3.0\"\n    )\n    assert instance is None\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC?delay=40.0\"\n    )\n    assert instance is None\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC?delay=invalid\"\n    )\n    assert instance is None\n\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC/DF1DEF\"\n    )\n    assert isinstance(instance, NotifyAprs)\n    assert instance.url(privacy=True).startswith(\n        \"aprs://DF1JSL-15:****@D...C/D...F?\"\n    )\n    assert instance.notify(\"test\") is True\n\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC-1/DF1ABC/DF1ABC-15\"\n    )\n    assert isinstance(instance, NotifyAprs)\n    assert instance.url(privacy=True).startswith(\n        \"aprs://DF1JSL-15:****@D...1/D...C/D...5?\"\n    )\n    assert instance.notify(\"test\") is True\n\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@?to=DF1ABC,DF1DEF\"\n    )\n    assert isinstance(instance, NotifyAprs)\n    assert instance.url(privacy=True).startswith(\n        \"aprs://DF1JSL-15:****@D...C/D...F?\"\n    )\n    assert instance.notify(\"test\") is True\n\n    # Test Locale settings\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC?locale=EURO\"\n    )\n    assert isinstance(instance, NotifyAprs)\n    assert instance.url(privacy=True).startswith(\n        \"aprs://DF1JSL-15:****@D...C?\"\n    )\n    # we used the default locale, so no setting\n    assert \"locale=\" not in instance.url(privacy=True)\n    assert instance.notify(\"test\") is True\n\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC?locale=NOAM\"\n    )\n    assert isinstance(instance, NotifyAprs)\n    assert instance.url(privacy=True).startswith(\n        \"aprs://DF1JSL-15:****@D...C?\"\n    )\n    # locale is set in URL\n    assert \"locale=NOAM\" in instance.url(privacy=True)\n    assert instance.notify(\"test\") is True\n\n    # Invalid locale\n    assert (\n        apprise.Apprise.instantiate(\n            \"aprs://DF1JSL-15:12345@DF1ABC?locale=invalid\"\n        )\n        is None\n    )\n\n    # Invalid call signs\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@abcdefghi/a\"\n    )\n\n    # We still instantiate\n    assert isinstance(instance, NotifyAprs)\n\n    # We still load our bad entries\n    assert instance.url(privacy=True).startswith(\n        \"aprs://DF1JSL-15:****@A...I/A...A?\"\n    )\n\n    # But with only bad entries, we have nothing to notify\n    assert instance.notify(\"test\") is False\n\n    # Enforces a close\n    del instance\n\n\n@mock.patch(\"socket.create_connection\")\ndef test_plugin_aprs_edge_cases(mock_create_connection):\n    \"\"\"NotifyAprs() Edge Cases.\"\"\"\n\n    # A socket object\n    sobj = mock.Mock()\n    sobj.return_value = 1\n    sobj.getpeername.return_value = (\"localhost\", 1234)\n    sobj.socket_close.return_value = None\n    sobj.setblocking.return_value = True\n    sobj.recv.return_value = \"ping\\npong pong DF1JSL-15 verified pong\".encode(\n        \"latin-1\"\n    )\n    sobj.sendall.return_value = True\n    sobj.settimeout.return_value = True\n\n    # Prepare Mock\n    mock_create_connection.return_value = sobj\n\n    # Valid instances\n    instance = apprise.Apprise.instantiate(\n        \"aprs://DF1JSL-15:12345@DF1ABC/DF1DEF\"\n    )\n    assert isinstance(instance, NotifyAprs)\n\n    # our URL Identifier\n    assert isinstance(instance.url_id(), str)\n\n    # Objects read\n    assert len(instance) == 2\n\n    # Bad data\n    sobj.recv.return_value = \"one line\".encode(\"latin-1\")\n    assert instance.notify(body=\"body\", title=\"title\") is False\n    sobj.recv.return_value = \"\\n\\n\\n\".encode(\"latin-1\")\n    assert instance.notify(body=\"body\", title=\"title\") is False\n    sobj.recv.return_value = \"\".encode(\"latin-1\")\n    assert instance.notify(body=\"body\", title=\"title\") is False\n    sobj.recv.return_value = \"\\ndata\".encode(\"latin-1\")\n    assert instance.notify(body=\"body\", title=\"title\") is False\n    # Different Call-Sign then what we logged in as\n    sobj.recv.return_value = \"ping\\npong pong DF1JSL-14 verified, pong\".encode(\n        \"latin-1\"\n    )\n    assert instance.notify(body=\"body\", title=\"title\") is False\n    # Unverified\n    sobj.recv.return_value = (\n        \"ping\\npong pong DF1JSL-15 unverified, pong\".encode(\"latin-1\")\n    )\n    assert instance.notify(body=\"body\", title=\"title\") is False\n\n    #\n    # Test Login edge cases\n    #\n    sobj.return_value = False\n    assert instance.aprsis_login() is False\n    sobj.return_value = 1\n    sobj.recv.return_value = \"\".encode(\"latin-1\")\n    assert instance.aprsis_login() is False\n    sobj.recv.return_value = \"ping\\npong pong DF1JSL-15 verified pong\".encode(\n        \"latin-1\"\n    )\n\n    #\n    # Test Socket Send Exceptions\n    #\n    sobj.sendall.return_value = None\n    sobj.sendall.side_effect = socket.gaierror(\"gaierror\")\n    # No connection\n    assert instance.socket_send(\"data\") is False\n    # Ensure we have a connection before calling socket_send()\n    assert instance.socket_open() is True\n    assert instance.socket_send(\"data\") is False\n    sobj.sendall.side_effect = socket.timeout(\"timeout\")\n    assert instance.socket_open() is True\n    assert instance.socket_send(\"data\") is False\n    assert instance.socket_open() is True\n    sobj.sendall.side_effect = OSError(\"error\")\n    assert instance.socket_send(\"data\") is False\n\n    # Login is impacted by socket_send\n    sobj.return_value = 1\n    assert instance.socket_open() is True\n    assert instance.aprsis_login() is False\n\n    # Return some of our\n    sobj.sendall.side_effect = None\n    sobj.sendall.return_value = True\n\n    assert instance.socket_open() is True\n    sobj.close.return_value = None\n    sobj.close.side_effect = socket.gaierror(\"gaierror\")\n    instance.socket_close()\n    sobj.close.side_effect = socket.timeout(\"timeout\")\n    instance.socket_close()\n    sobj.close.side_effect = OSError(\"error\")\n    instance.socket_close()\n    sobj.return_value = None\n    instance.socket_close()\n    # Socket isn't open; so we can't get content\n    assert instance.socket_receive(100) is False\n    sobj.close.side_effect = None\n    sobj.close.return_value = None\n    # Double close test\n    instance.socket_close()\n\n    sobj.return_value = 1\n    mock_create_connection.return_value = None\n    mock_create_connection.side_effect = socket.gaierror(\"gaierror\")\n    assert instance.socket_open() is False\n    assert instance.notify(\"test\") is False\n    mock_create_connection.side_effect = socket.timeout(\"timeout\")\n    assert instance.socket_open() is False\n    assert instance.notify(\"test\") is False\n    mock_create_connection.side_effect = OSError(\"error\")\n    assert instance.socket_open() is False\n    assert instance.notify(\"test\") is False\n    mock_create_connection.side_effect = ConnectionError(\"ConnectionError\")\n    assert instance.socket_open() is False\n    assert instance.notify(\"test\") is False\n\n    # Restore our good connection\n    mock_create_connection.return_value = sobj\n    mock_create_connection.side_effect = None\n\n    # Functionality has been restored\n    assert instance.socket_open() is True\n\n    # Now play with getpeername\n    sobj.getpeername.return_value = None\n    sobj.getpeername.side_effect = ValueError(\"getpeername ValueError\")\n    assert instance.socket_open() is True\n\n    sobj.getpeername.return_value = (\"localhost\", 1234)\n    assert instance.socket_open() is True\n    # Test different receive settings\n    assert instance.socket_receive(0)\n    assert instance.socket_receive(-1)\n    assert instance.socket_receive(100)\n\n    sobj.recv.side_effect = socket.gaierror(\"gaierror\")\n    assert instance.socket_open() is True\n    assert instance.socket_receive(100) is False\n    sobj.recv.side_effect = socket.timeout(\"timeout\")\n    assert instance.socket_open() is True\n    assert instance.socket_receive(100) is False\n    sobj.recv.side_effect = OSError(\"error\")\n    assert instance.socket_open() is True\n    assert instance.socket_receive(100) is False\n\n    # Restore\n    sobj.recv.side_effect = None\n    sobj.recv.return_value = \"ping\\npong pong DF1JSL-15 verified pong\".encode(\n        \"latin-1\"\n    )\n\n    # Simulate a successful connection, but a failed notification\n    # To do this we need to have a login succeed, but the second call to send\n    # to fail\n    sobj.sendall.return_value = True\n    assert instance.notify(\"test\") is True\n\n    sobj.sendall.return_value = None\n    sobj.sendall.side_effect = (True, socket.gaierror(\"gaierror\"))\n    assert instance.notify(\"test\") is False\n\n    sobj.sendall.return_value = True\n    sobj.sendall.side_effect = None\n    del sobj\n\n\ndef test_plugin_aprs_config_files():\n    \"\"\"NotifyAprs() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - aprs://DF1JSL-15:12345@DF1ABC\":\n          - locale: NOAM\n\n      - aprs://DF1JSL-15:12345@DF1ABC:\n          - locale: SOAM\n\n      - aprs://DF1JSL-15:12345@DF1ABC:\n          - locale: EURO\n\n      - aprs://DF1JSL-15:12345@DF1ABC:\n          - locale: ASIA\n\n      - aprs://DF1JSL-15:12345@DF1ABC:\n          - locale: AUNZ\n\n      - aprs://DF1JSL-15:12345@DF1ABC:\n          - locale: ROTA\n\n      # This will fail to load because the locale is bad\n      - aprs://DF1JSL-15:12345@DF1ABC:\n          - locale: aprs_invalid\n    \"\"\"\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    assert len(ac.servers()) == 6\n    assert len(aobj) == 6\n"
  },
  {
    "path": "tests/test_plugin_bark.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.bark import NotifyBark\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"bark://\",\n        {\n            # No no host\n            \"instance\": None,\n        },\n    ),\n    (\n        \"bark://:@/\",\n        {\n            # just invalid all around\n            \"instance\": None,\n        },\n    ),\n    (\n        \"bark://localhost\",\n        {\n            # No Device Key specified\n            \"instance\": NotifyBark,\n            # Expected notify() response False (because we won't be able\n            # to actually notify anything if no device_key was specified\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key\",\n        {\n            # Everything is okay\n            \"instance\": NotifyBark,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"bark://192.168.0.6:8081/\",\n        },\n    ),\n    (\n        \"bark://user@192.168.0.6:8081/device_key\",\n        {\n            # Everything is okay (test with user)\n            \"instance\": NotifyBark,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"bark://user@192.168.0.6:8081/\",\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?sound=invalid\",\n        {\n            # bad sound, but we go ahead anyway\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?sound=alarm\",\n        {\n            # alarm.caf sound loaded\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?sound=NOiR.cAf\",\n        {\n            # noir.caf sound loaded\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?badge=100\",\n        {\n            # set badge\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"barks://192.168.0.6:8081/device_key/?badge=invalid\",\n        {\n            # set invalid badge\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"barks://192.168.0.6:8081/device_key/?badge=-12\",\n        {\n            # set invalid badge\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?category=apprise\",\n        {\n            # set category\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?image=no\",\n        {\n            # do not display image\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?group=apprise\",\n        {\n            # set group\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=invalid\",\n        {\n            # bad level, but we go ahead anyway\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/?to=device_key\",\n        {\n            # test use of to= argument\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?click=http://localhost\",\n        {\n            # Our click link\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=active\",\n        {\n            # active level\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=critical\",\n        {\n            # critical level\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=critical&volume=10\",\n        {\n            # critical level with volume 10\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=critical&volume=invalid\",\n        {\n            # critical level with invalid volume\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=critical&volume=11\",\n        {\n            # volume > 10\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=critical&volume=-1\",\n        {\n            # volume < 0\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?level=critical&volume=\",\n        {\n            # volume None\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://user:pass@192.168.0.5:8086/device_key/device_key2/\",\n        {\n            # Everything is okay\n            \"instance\": NotifyBark,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"bark://user:****@192.168.0.5:8086/\",\n        },\n    ),\n    (\n        \"barks://192.168.0.7/device_key/\",\n        {\n            \"instance\": NotifyBark,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"barks://192.168.0.7/device_key\",\n        },\n    ),\n    (\n        \"bark://192.168.0.7/device_key\",\n        {\n            \"instance\": NotifyBark,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?icon=https://example.com/icon.png\",\n        {\n            # set custom icon\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?icon=https://example.com/icon.png&image=no\",\n        {\n            # set custom icon and disable default image\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?call=1\",\n        {\n            # set call parameter to repeat ringtone\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?call=1&sound=alarm&level=critical\",\n        {\n            # set call parameter with other parameters\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?format=markdown\",\n        {\n            # enable markdown mode via global format parameter\n            \"instance\": NotifyBark,\n        },\n    ),\n    (\n        \"bark://192.168.0.6:8081/device_key/?format=text\",\n        {\n            # explicitly set text format (default behavior)\n            \"instance\": NotifyBark,\n        },\n    ),\n)\n\n\ndef test_plugin_bark_urls():\n    \"\"\"NotifyBark() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_base_formatting.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom random import choice\nfrom string import ascii_uppercase as str_alpha, digits as str_num\nfrom unittest import mock\n\nimport pytest\nimport requests\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    NotifyBase,\n    NotifyFormat,\n    OverflowMode,\n)\n\nlogging.disable(logging.CRITICAL)\n\n\ndef assert_body(\n    source: str,\n    offset: int,\n    chunk_body: str,\n) -> int:\n    \"\"\"\n    Assert that `chunk_body` comes from `source` starting at `offset`,\n    accounting for leading/trailing vertical whitespace that _apply_overflow\n    trims away. Returns the updated offset.\n    \"\"\"\n    segment = source[offset : offset + len(chunk_body)]\n    ws_diff = len(segment) - len(\n        segment.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip()\n    )\n\n    assert (\n        source[offset : offset + len(chunk_body) + ws_diff]\n        .lstrip(\"\\r\\n\\x0b\\x0c\")\n        .rstrip()\n        == chunk_body\n    )\n\n    return offset + len(chunk_body) + ws_diff\n\n\ndef test_notify_overflow_truncate_with_amalgamation():\n    \"\"\"\n    API: Overflow With Amalgamation Truncate Functionality Testing\n\n    \"\"\"\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num + \" \") for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # We should throw an exception because our specified overflow is wrong.\n    with pytest.raises(TypeError):\n        # Load our object\n        obj = TestNotification(overflow=\"invalid\")\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title, overflow=None)\n    chunks = obj._apply_overflow(body=\"\", title=\"\", overflow=None)\n    chunks = obj._apply_overflow(body=body, title=\"\", overflow=None)\n    chunks = obj._apply_overflow(body=body, title=title, overflow=None)\n    chunks = obj._apply_overflow(\n        body=body, title=title, overflow=OverflowMode.SPLIT\n    )\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Truncated body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length of just 10\n        body_maxlen = 10\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    # Body is lost as the title prevails over everything\n    assert chunks[0].get(\"body\") == \"\"\n    # Title is not longer then the maximum size the body can be due to\n    # amalgamationflag:\n    assert title[: obj.body_maxlen] == chunks[0].get(\"title\")\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length of just 10\n        body_maxlen = 10\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n\n    assert chunks[0].get(\"body\") == \"\"\n    # body_maxlen prevails due to it being smaller and amalgamation flag set\n    assert title[0 : obj.body_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Append title to body + Truncated body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length of just 100\n        body_maxlen = 100\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n\n    obj.notify_format = NotifyFormat.HTML\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n\n    obj.notify_format = NotifyFormat.MARKDOWN\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n\n    obj.notify_format = NotifyFormat.TEXT\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n\n    # The below line should be read carefully... We're actually testing to see\n    # that our title is matched against our body. Behind the scenes, the title\n    # was appended to the body. The body was then truncated to the maxlen.\n    # The thing is, since the title is so large, all of the body was lost\n    # and a good chunk of the title was too.  The message sent will just be a\n    # small portion of the title\n    assert len(chunks[0].get(\"body\")) == obj.body_maxlen\n    assert title[0 : obj.body_maxlen] == chunks[0].get(\"body\")\n\n\ndef test_notify_overflow_truncate_no_amalgamation():\n    \"\"\"\n    API: Overflow No Amalgamation Truncate Functionality Testing\n\n    \"\"\"\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num + \" \") for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # We should throw an exception because our specified overflow is wrong.\n    with pytest.raises(TypeError):\n        # Load our object\n        obj = TestNotification(overflow=\"invalid\")\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title, overflow=None)\n    chunks = obj._apply_overflow(body=\"\", title=\"\", overflow=None)\n    chunks = obj._apply_overflow(body=body, title=\"\", overflow=None)\n    chunks = obj._apply_overflow(body=body, title=title, overflow=None)\n    chunks = obj._apply_overflow(\n        body=body, title=title, overflow=OverflowMode.SPLIT\n    )\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Truncated body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length of just 10\n        body_maxlen = 10\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert body[0 : obj.body_maxlen].lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[\n        0\n    ].get(\"body\")\n    assert title == chunks[0].get(\"title\")\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length of just 10\n        body_maxlen = 10\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    # No amalgamation set so our body aligns in size (no -2 like previous\n    # test)\n    assert body[0 : obj.body_maxlen].lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[\n        0\n    ].get(\"body\")\n    assert title == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Append title to body + Truncated body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length of just 100\n        body_maxlen = 100\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.TRUNCATE)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n\n    obj.notify_format = NotifyFormat.HTML\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n\n    obj.notify_format = NotifyFormat.MARKDOWN\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n\n    obj.notify_format = NotifyFormat.TEXT\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n\n    # The below line should be read carefully... We're actually testing to see\n    # that our title is matched against our body. Behind the scenes, the title\n    # was appended to the body. The body was then truncated to the maxlen.\n    # The thing is, since the title is so large, all of the body was lost\n    # and a good chunk of the title was too.  The message sent will just be a\n    # small portion of the title\n    assert len(chunks[0].get(\"body\")) == obj.body_maxlen\n    assert title[0 : obj.body_maxlen] == chunks[0].get(\"body\")\n\n\ndef test_notify_overflow_split_with_amalgamation():\n    \"\"\"\n    API: Overflow With Amalgamation Splits Functionality Testing\n\n    \"\"\"\n\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num) for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Another edge case where the title just isn't that long leaving\n    # a lot of space for the [xx/xx] entries (no truncation needed)\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title[:20])\n    c_len = len(\" [X/X]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert title[:20] == chunk.get(\"title\")[:-c_len]\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:01}/{len(chunks):01}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test forcing overflow_display_title_once\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        #  Only display title once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 400\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    c_len = len(\" [XXXX/XXXX]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert (\n            title[: obj.title_maxlen][:-c_len] == chunk.get(\"title\")[:-c_len]\n        )\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:04}/{len(chunks):04}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off\n    new_body = body * 2500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 150\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 5\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Next Test: Append title to body + split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length based on the title. Make sure it's an int.\n        body_maxlen = int(title_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    # Our final product is that our title has been appended to our body to\n    # create one great big body. As a result we'll get quite a few lines back\n    # now.\n    offset = 0\n\n    # Our body will look like this in small chunks at the end of the day\n    bulk = title + \"\\r\\n\" + body\n\n    # Due to the new line added to the end\n    assert len(chunks) == (\n        # wrap division in int() so Python 3 doesn't convert it to a float on\n        # us\n        int(len(bulk) / obj.body_maxlen)\n        + (1 if len(bulk) % obj.body_maxlen else 0)\n    )\n\n    for chunk in chunks:\n        # Verification\n        assert len(chunk.get(\"title\")) == 0\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is empty every time\n        assert chunk.get(\"title\") == \"\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            bulk,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test case where our title_len is shorter then the value\n    # that would otherwise trigger the [XX/XX] elements\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is truncated and no counter added\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Scenario where the title length is larger than the body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 50\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it due to it's length\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to 50 due\n            # to amalamation.  The lowest value always prevails\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n\ndef test_notify_overflow_split_with_amalgamation_force_title_always():\n    \"\"\"\n    API: Overflow With Amalgamation (title alaways Split Functionality Testing\n\n    \"\"\"\n\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num) for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Another edge case where the title just isn't that long leaving\n    # a lot of space for the [xx/xx] entries (no truncation needed)\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title[:20])\n    offset = 0\n    c_len = len(\" [X/X]\")\n    assert len(chunks) == 5\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert title[:20] == chunk.get(\"title\")[:-c_len]\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:01}/{len(chunks):01}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test forcing overflow_display_title_once\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 400\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    c_len = len(\" [XXXX/XXXX]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert (\n            title[: obj.title_maxlen][:-c_len] == chunk.get(\"title\")[:-c_len]\n        )\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:04}/{len(chunks):04}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off\n    new_body = body * 2500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 150\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 5\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    # overflow_display_title_once whle set to False is still ignored\n    # because our title_maxlen > body_maxlen and a full title was\n    # provided\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    offset = 0\n    # overflow_display_title_once whle set to False is still ignored\n    # because our title_maxlen > body_maxlen and a full title was\n    # provided\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Run again but with a smaller title\n    #\n    chunks = obj._apply_overflow(body=new_body, title=title[:30])\n    # overflow_display_title_once whle set to False is still ignored\n    # because our body_maxlen (after title has been calculated with it)\n    # is less then the overflow_display_count_threshold; hence a message\n    # must be a certain minimum size in order to kick in\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title doesn't change\n        assert len(chunk.get(\"title\")) == 30\n        assert title[:30] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Next Test: Append title to body + split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length based on the title. Make sure it's an int.\n        body_maxlen = int(title_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    # Our final product is that our title has been appended to our body to\n    # create one great big body. As a result we'll get quite a few lines back\n    # now.\n    offset = 0\n\n    # Our body will look like this in small chunks at the end of the day\n    bulk = title + \"\\r\\n\" + body\n\n    # Due to the new line added to the end\n    assert len(chunks) == (\n        # wrap division in int() so Python 3 doesn't convert it to a float on\n        # us\n        int(len(bulk) / obj.body_maxlen)\n        + (1 if len(bulk) % obj.body_maxlen else 0)\n    )\n\n    for chunk in chunks:\n        # Verification\n        assert len(chunk.get(\"title\")) == 0\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is empty every time\n        assert chunk.get(\"title\") == \"\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            bulk,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test case where our title_len is shorter then the value\n    # that would otherwise trigger the [XX/XX] elements\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is truncated and no counter added\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Scenario where the title length is larger than the body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 50\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n\n    # overflow_display_title_once whle set to False is still ignored\n    # because our title_maxlen > body_maxlen and a full title was\n    # provided\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it due to it's length\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to 50 due\n            # to amalamation.  The lowest value always prevails\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n\ndef test_notify_overflow_split_with_amalgamation_force_title_once():\n    \"\"\"\n    API: Overflow With Amalgamation (title once) Split Functionality Testing\n\n    \"\"\"\n\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num) for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Another edge case where the title just isn't that long leaving\n    # a lot of space for the [xx/xx] entries (no truncation needed)\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title[:20])\n    offset = 0\n    assert len(chunks) == 5\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == 20\n            assert title[:20] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test forcing overflow_display_title_once\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        #  Only display title once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 400\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off\n    new_body = body * 2500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 150\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 5\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Next Test: Append title to body + split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length based on the title. Make sure it's an int.\n        body_maxlen = int(title_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    # Our final product is that our title has been appended to our body to\n    # create one great big body. As a result we'll get quite a few lines back\n    # now.\n    offset = 0\n\n    # Our body will look like this in small chunks at the end of the day\n    bulk = title + \"\\r\\n\" + body\n\n    # Due to the new line added to the end\n    assert len(chunks) == (\n        # wrap division in int() so Python 3 doesn't convert it to a float on\n        # us\n        int(len(bulk) / obj.body_maxlen)\n        + (1 if len(bulk) % obj.body_maxlen else 0)\n    )\n\n    for chunk in chunks:\n        # Verification\n        assert len(chunk.get(\"title\")) == 0\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is empty every time\n        assert chunk.get(\"title\") == \"\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            bulk,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test case where our title_len is shorter then the value\n    # that would otherwise trigger the [XX/XX] elements\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Scenario where the title length is larger than the body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 50\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it due to it's length\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to 50 due\n            # to amalamation.  The lowest value always prevails\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n\ndef test_notify_overflow_split_no_amalgamation():\n    \"\"\"\n    API: Overflow No Amalgamation Splits Functionality Testing\n\n    \"\"\"\n\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num) for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    c_len = len(\" [X/X]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert title[:-c_len] == chunk.get(\"title\")[:-c_len]\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:01}/{len(chunks):01}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Another edge case where the title just isn't that long leaving\n    # a lot of space for the [xx/xx] entries (no truncation needed)\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title[:20])\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert title[:20] == chunk.get(\"title\")[:-c_len]\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:01}/{len(chunks):01}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 400\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    c_len = len(\" [XXXX/XXXX]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n        # Our title has a counter added to it\n        assert (\n            title[: obj.title_maxlen][:-c_len] == chunk.get(\"title\")[:-c_len]\n        )\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:04}/{len(chunks):04}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off\n    new_body = body * 4500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 150\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 5\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    c_len = len(\" [XX/XX]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert (\n            title[: obj.title_maxlen][:-c_len] == chunk.get(\"title\")[:-c_len]\n        )\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:02}/{len(chunks):02}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Next Test: Append title to body + split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length based on the title. Make sure it's an int.\n        body_maxlen = int(title_len / 4)\n\n        # No Amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    # Our final product is that our title has been appended to our body to\n    # create one great big body. As a result we'll get quite a few lines back\n    # now.\n    offset = 0\n\n    # Our body will look like this in small chunks at the end of the day\n    bulk = title + \"\\r\\n\" + body\n\n    # Due to the new line added to the end\n    assert len(chunks) == (\n        # wrap division in int() so Python 3 doesn't convert it to a float on\n        # us\n        int(len(bulk) / obj.body_maxlen)\n        + (1 if len(bulk) % obj.body_maxlen else 0)\n    )\n\n    for chunk in chunks:\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is empty every time\n        assert chunk.get(\"title\") == \"\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            bulk,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test case where our title_len is shorter then the value\n    # that would otherwise trigger the [XX/XX] elements\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # No Amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is truncated and no counter added\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Scenario where the title length is larger than the body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 50\n\n        # No Amalgamation\n        overflow_amalgamate_title = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is truncated and no counter added\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n\ndef test_notify_overflow_split_no_amalgamation_force_title_always():\n    \"\"\"\n    API: Overflow No Amalgamation (title always) Split Functionality Testing\n\n    \"\"\"\n\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num) for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    c_len = len(\" [X/X]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert title[:-c_len] == chunk.get(\"title\")[:-c_len]\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:01}/{len(chunks):01}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Another edge case where the title just isn't that long leaving\n    # a lot of space for the [xx/xx] entries (no truncation needed)\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title[:20])\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert title[:20] == chunk.get(\"title\")[:-c_len]\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:01}/{len(chunks):01}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 400\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    c_len = len(\" [XXXX/XXXX]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n        # Our title has a counter added to it\n        assert (\n            title[: obj.title_maxlen][:-c_len] == chunk.get(\"title\")[:-c_len]\n        )\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:04}/{len(chunks):04}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off\n    new_body = body * 4500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 150\n\n        # No amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 5\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    c_len = len(\" [XX/XX]\")\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has a counter added to it\n        assert (\n            title[: obj.title_maxlen][:-c_len] == chunk.get(\"title\")[:-c_len]\n        )\n        assert chunk.get(\"title\")[-c_len:] == f\" [{idx:02}/{len(chunks):02}]\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Next Test: Append title to body + split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length based on the title. Make sure it's an int.\n        body_maxlen = int(title_len / 4)\n\n        # No Amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    # Our final product is that our title has been appended to our body to\n    # create one great big body. As a result we'll get quite a few lines back\n    # now.\n    offset = 0\n\n    # Our body will look like this in small chunks at the end of the day\n    bulk = title + \"\\r\\n\" + body\n\n    # Due to the new line added to the end\n    assert len(chunks) == (\n        # wrap division in int() so Python 3 doesn't convert it to a float on\n        # us\n        int(len(bulk) / obj.body_maxlen)\n        + (1 if len(bulk) % obj.body_maxlen else 0)\n    )\n\n    for chunk in chunks:\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is empty every time\n        assert chunk.get(\"title\") == \"\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            bulk,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test case where our title_len is shorter then the value\n    # that would otherwise trigger the [XX/XX] elements\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # No Amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is truncated and no counter added\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Scenario where the title length is larger than the body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 50\n\n        # No Amalgamation\n        overflow_amalgamate_title = False\n\n        # Force title to be displayed always\n        overflow_display_title_once = False\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for _idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is truncated and no counter added\n        assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n\ndef test_notify_overflow_split_no_amalgamation_force_title_once():\n    \"\"\"\n    API: Overflow No Amalgamation (title once) Split Functionality Testing\n\n    \"\"\"\n\n    #\n    # A little preparation\n    #\n\n    # Number of characters per line\n    row = 24\n\n    # Some variables we use to control the data we work with\n    body_len = 1024\n    title_len = 1024\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num) for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # the new lines add a large amount to our body; lets force the content\n    # back to being 1024 characters.\n    body = body[0:1024]\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 10\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Line Count Control\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 5\n\n        # Maximum number of lines\n        body_max_line_count = 5\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    assert len(chunks[0].get(\"body\").split(\"\\n\")) == obj.body_max_line_count\n    assert title[0 : obj.title_maxlen] == chunks[0].get(\"title\")\n\n    #\n    # Next Test: Split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Another edge case where the title just isn't that long leaving\n    # a lot of space for the [xx/xx] entries (no truncation needed)\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title[:20])\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == 20\n            assert title[:20] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test forcing overflow_display_title_once\n    #\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = title_len\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The body length prevails due to our amalgamation flag\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 400\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Body chunk is beyond 4 digits, so [XXXX/XXXX] is turned off\n    new_body = body * 2500\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    # Test larger messages\n    # and that the body remains untouched\n    class TestNotification(NotifyBase):\n\n        # Test title max length\n        title_maxlen = 150\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 150\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    new_body = body * 5\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=\"\")\n    chunks = obj._apply_overflow(body=new_body, title=title)\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Next Test: Append title to body + split body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Enforce no title\n        title_maxlen = 0\n\n        # Enforce a body length based on the title. Make sure it's an int.\n        body_maxlen = int(title_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    # Our final product is that our title has been appended to our body to\n    # create one great big body. As a result we'll get quite a few lines back\n    # now.\n    offset = 0\n\n    # Our body will look like this in small chunks at the end of the day\n    bulk = title + \"\\r\\n\" + body\n\n    # Due to the new line added to the end\n    assert len(chunks) == (\n        # wrap division in int() so Python 3 doesn't convert it to a float on\n        # us\n        int(len(bulk) / obj.body_maxlen)\n        + (1 if len(bulk) % obj.body_maxlen else 0)\n    )\n\n    for chunk in chunks:\n        # Verification\n        assert len(chunk.get(\"title\")) == 0\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title is empty every time\n        assert chunk.get(\"title\") == \"\"\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            bulk,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Test case where our title_len is shorter then the value\n    # that would otherwise trigger the [XX/XX] elements\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = int(body_len / 4)\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # No counter is displayed because our title is so enormous\n        # We switch to a display title on first message only\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # The length of the body prevails our title due to it being\n            # so much smaller then our title length\n            assert len(chunk.get(\"title\")) == obj.title_maxlen\n            assert title[: obj.title_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n    #\n    # Scenario where the title length is larger than the body\n    #\n\n    class TestNotification(NotifyBase):\n\n        # Set a small title length\n        title_maxlen = 100\n\n        # Enforce a body length. Make sure it's an int.\n        body_maxlen = 50\n\n        # With amalgamation\n        overflow_amalgamate_title = True\n\n        # Force title displayed once\n        overflow_display_title_once = True\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestNotification(overflow=OverflowMode.SPLIT)\n    assert obj is not None\n\n    # Verify that we break the title to a max length of our title_max\n    # and that the body remains untouched\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n\n    offset = 0\n    for idx, chunk in enumerate(chunks, start=1):\n        # Verification\n        assert len(chunk.get(\"title\")) <= obj.title_maxlen\n        assert len(chunk.get(\"body\")) <= obj.body_maxlen\n\n        # Our title has no counter added to it due to it's length\n        if idx > 1:\n            # Empty (no title displayed on following entries\n            assert chunk.get(\"title\") == \"\"\n        else:\n            # Because 150 is what we set the title limit to 50 due\n            # to amalamation.  The lowest value always prevails\n            assert len(chunk.get(\"title\")) == obj.body_maxlen\n            assert title[: obj.body_maxlen] == chunk.get(\"title\")\n\n        # Our body is only broken up; not lost\n        offset = assert_body(\n            new_body,\n            offset,\n            chunk.get(\"body\"),\n        )\n\n\ndef test_notify_markdown_general():\n    \"\"\"\n    API: Markdown General Testing\n\n    \"\"\"\n\n    #\n    # A little preparation\n    #\n\n    #\n    # First Test: Truncated Title\n    #\n    class TestMarkdownNotification(NotifyBase):\n\n        # Force our title to wrap\n        title_maxlen = 0\n\n        # Default Notify Format\n        notify_format = NotifyFormat.MARKDOWN\n\n        def __init__(self, *args, **kwargs):\n            super().__init__(**kwargs)\n\n        def notify(self, *args, **kwargs):\n            # Pretend everything is okay\n            return True\n\n    # Load our object\n    obj = TestMarkdownNotification()\n    assert obj is not None\n\n    # A bad header\n    title = \" # \"\n    body = \"**Test Body**\"\n\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(body=body, title=title)\n    assert len(chunks) == 1\n    # whitspace is trimmed\n    assert chunks[0].get(\"body\") == \"#\\r\\n**Test Body**\"\n    assert chunks[0].get(\"title\") == \"\"\n\n    # If we know our input is text however, we perform manipulation\n    chunks = obj._apply_overflow(body=\"\", title=title)\n    chunks = obj._apply_overflow(body=\"\", title=\"\")\n    chunks = obj._apply_overflow(body=body, title=\"\")\n    chunks = obj._apply_overflow(\n        body=body, title=title, body_format=NotifyFormat.TEXT\n    )\n    assert len(chunks) == 1\n    # Our title get's stripped off since it's not of valid markdown\n    assert body.lstrip(\"\\r\\n\\x0b\\x0c\").rstrip() == chunks[0].get(\"body\")\n    assert chunks[0].get(\"title\") == \"\"\n\n\n@mock.patch(\"requests.request\")\ndef test_notify_emoji_general(mock_request):\n    \"\"\"\n    API: Emoji General Testing\n\n    \"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = response\n\n    # Set up our emojis\n    title = \":smile:\"\n    body = \":grin:\"\n\n    # general reference used below (using default values)\n    asset = AppriseAsset()\n\n    #\n    # Test default emoji asset value\n    #\n\n    # Load our object\n    ap_obj = Apprise(asset=asset)\n    ap_obj.add(\"json://localhost\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # No changes\n    assert dataset[\"title\"] == title\n    assert dataset[\"message\"] == body\n\n    mock_request.reset_mock()\n\n    #\n    # Test URL over-ride while default value set in asset\n    #\n\n    # Load our object\n    ap_obj = Apprise(asset=asset)\n    ap_obj.add(\"json://localhost?emojis=no\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # No changes\n    assert dataset[\"title\"] == title\n    assert dataset[\"message\"] == body\n\n    mock_request.reset_mock()\n\n    #\n    # Test URL over-ride while default value set in asset pt 2\n    #\n\n    # Load our object\n    ap_obj = Apprise(asset=asset)\n    ap_obj.add(\"json://localhost?emojis=yes\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # Emoji's are displayed\n    assert dataset[\"title\"] == \"😄\"\n    assert dataset[\"message\"] == \"😃\"\n\n    mock_request.reset_mock()\n\n    #\n    # Test URL over-ride while default value set in asset pt 2\n    #\n\n    # Load our object\n    ap_obj = Apprise(asset=asset)\n    ap_obj.add(\"json://localhost?emojis=no\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # No changes\n    assert dataset[\"title\"] == title\n    assert dataset[\"message\"] == body\n\n    mock_request.reset_mock()\n\n    #\n    # Test Default Emoji settings\n    #\n\n    # Set our interpret emoji's flag\n    asset = AppriseAsset(interpret_emojis=True)\n\n    # Re-create our object\n    ap_obj = Apprise(asset=asset)\n\n    # Load our object\n    ap_obj.add(\"json://localhost\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # emoji's are displayed\n    assert dataset[\"title\"] == \"😄\"\n    assert dataset[\"message\"] == \"😃\"\n\n    mock_request.reset_mock()\n\n    #\n    # With Emoji's turned on by default, the user can optionally turn them\n    # off.\n    #\n\n    # Re-create our object\n    ap_obj = Apprise(asset=asset)\n\n    ap_obj.add(\"json://localhost?emojis=no\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # No changes\n    assert dataset[\"title\"] == title\n    assert dataset[\"message\"] == body\n\n    mock_request.reset_mock()\n\n    #\n    # With Emoji's turned on by default, there is no change when emojis\n    # flag is set to on\n    #\n\n    # Re-create our object\n    ap_obj = Apprise(asset=asset)\n\n    ap_obj.add(\"json://localhost?emojis=yes\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # emoji's are displayed\n    assert dataset[\"title\"] == \"😄\"\n    assert dataset[\"message\"] == \"😃\"\n\n    mock_request.reset_mock()\n\n    #\n    # Enforce the disabling of emojis\n    #\n\n    # Set our interpret emoji's flag\n    asset = AppriseAsset(interpret_emojis=False)\n\n    # Re-create our object\n    ap_obj = Apprise(asset=asset)\n\n    # Load our object\n    ap_obj.add(\"json://localhost\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # Disabled - no emojis\n    assert dataset[\"title\"] == title\n    assert dataset[\"message\"] == body\n\n    mock_request.reset_mock()\n\n    #\n    # Enforce the disabling of emojis\n    #\n\n    # Set our interpret emoji's flag\n    asset = AppriseAsset(interpret_emojis=False)\n\n    # Re-create our object\n    ap_obj = Apprise(asset=asset)\n\n    # Load our object\n    ap_obj.add(\"json://localhost?emojis=yes\")\n    assert len(ap_obj) == 1\n\n    assert ap_obj.notify(title=title, body=body) is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    dataset = json.loads(details[1][\"data\"])\n\n    # Disabled - no emojis\n    assert dataset[\"title\"] == title\n    assert dataset[\"message\"] == body\n\n    mock_request.reset_mock()\n"
  },
  {
    "path": "tests/test_plugin_bluesky.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timezone\nimport json\nimport logging\nimport os\nfrom unittest.mock import Mock, patch\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.bluesky import NotifyBlueSky\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\nTWITTER_SCREEN_NAME = \"apprise\"\n\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyBlueSky\n    ##################################\n    (\n        \"bluesky://\",\n        {\n            # Missing user and app_pass\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bluesky://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bluesky://app-pw\",\n        {\n            # Missing User\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bluesky://user@app-pw\",\n        {\n            \"instance\": NotifyBlueSky,\n            # Expected notify() response False (because we won't be able\n            # to detect our user)\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"bsky://user@****\",\n        },\n    ),\n    (\n        \"bluesky://user@app-pw1?cache=no\",\n        {\n            \"instance\": NotifyBlueSky,\n            # At minimum we need an access token and did; below has no did\n            \"requests_response_text\": {\n                \"accessJwt\": \"abcd\",\n                \"refreshJwt\": \"abcd\",\n            },\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"bluesky://user@app-pw2?cache=no\",\n        {\n            \"instance\": NotifyBlueSky,\n            # valid payload\n            \"requests_response_text\": {\n                \"accessJwt\": \"abcd\",\n                \"refreshJwt\": \"abcd\",\n                \"did\": \"did:plc:1234\",\n                # Support plc response\n                \"service\": [{\n                    \"type\": \"AtprotoPersonalDataServer\",\n                    \"serviceEndpoint\": \"https://example.pds.io\",\n                }],\n            },\n        },\n    ),\n    (\n        \"bluesky://user@app-pw3\",\n        {\n            # no cache; so we store our results\n            \"instance\": NotifyBlueSky,\n            # valid payload\n            \"requests_response_text\": {\n                \"accessJwt\": \"abcd\",\n                \"refreshJwt\": \"abcd\",\n                \"did\": \"did:plc:1234\",\n                # For handling attachments\n                \"blob\": \"content\",\n                # Support plc response\n                \"service\": [{\n                    \"type\": \"AtprotoPersonalDataServer\",\n                    \"serviceEndpoint\": \"https://example.pds.io\",\n                }],\n            },\n        },\n    ),\n    (\n        \"bluesky://user.example.ca@app-pw3\",\n        {\n            # no cache; so we store our results\n            \"instance\": NotifyBlueSky,\n            # valid payload\n            \"requests_response_text\": {\n                \"accessJwt\": \"abcd\",\n                \"refreshJwt\": \"abcd\",\n                \"did\": \"did:plc:1234\",\n                # For handling attachments\n                \"blob\": \"content\",\n                # Support plc response\n                \"service\": [{\n                    \"type\": \"AtprotoPersonalDataServer\",\n                    \"serviceEndpoint\": \"https://example.pds.io\",\n                }],\n            },\n        },\n    ),\n    # A duplicate of the entry above, this will cause cache to be referenced\n    (\n        \"bluesky://user@app-pw3\",\n        {\n            # no cache; so we store our results\n            \"instance\": NotifyBlueSky,\n            # valid payload\n            \"requests_response_text\": {\n                \"accessJwt\": \"abcd\",\n                \"refreshJwt\": \"abcd\",\n                \"did\": \"did:plc:1234\",\n                # For handling attachments\n                \"blob\": \"content\",\n                # Support plc response\n                \"service\": [{\n                    \"type\": \"AtprotoPersonalDataServer\",\n                    \"serviceEndpoint\": \"https://example.pds.io\",\n                }],\n            },\n        },\n    ),\n    (\n        \"bluesky://user@app-pw\",\n        {\n            \"instance\": NotifyBlueSky,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            \"requests_response_text\": {\n                \"accessJwt\": \"abcd\",\n                \"refreshJwt\": \"abcd\",\n                \"did\": \"did:plc:1234\",\n                # Support plc response\n                \"service\": [{\n                    \"type\": \"AtprotoPersonalDataServer\",\n                    \"serviceEndpoint\": \"https://example.pds.io\",\n                }],\n            },\n        },\n    ),\n    (\n        \"bluesky://user@app-pw\",\n        {\n            \"instance\": NotifyBlueSky,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n            \"requests_response_text\": {\n                \"accessJwt\": \"abcd\",\n                \"refreshJwt\": \"abcd\",\n                \"did\": \"did:plc:1234\",\n                # Support plc response\n                \"service\": [{\n                    \"type\": \"AtprotoPersonalDataServer\",\n                    \"serviceEndpoint\": \"https://example.pds.io\",\n                }],\n            },\n        },\n    ),\n)\n\n\ndef good_response(data=None):\n    \"\"\"Prepare a good response.\"\"\"\n    response = Mock()\n    response.content = json.dumps(\n        {\n            \"accessJwt\": \"abcd\",\n            \"refreshJwt\": \"abcd\",\n            \"did\": \"did:plc:1234\",\n            # Support plc response\n            \"service\": [{\n                \"type\": \"AtprotoPersonalDataServer\",\n                \"serviceEndpoint\": \"https://example.pds.io\",\n            }],\n            \"ratelimit-reset\": str(\n                int(datetime.now(timezone.utc).timestamp()) + 3600\n            ),\n            \"ratelimit-remaining\": \"10\",\n        }\n        if data is None\n        else data\n    )\n\n    response.status_code = requests.codes.ok\n\n    # Epoch time:\n    epoch = datetime.fromtimestamp(0, timezone.utc)\n\n    # Generate a very large rate-limit header window\n    response.headers = {\n        \"ratelimit-reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds() + 86400\n        ),\n        \"ratelimit-remaining\": \"1000\",\n    }\n\n    return response\n\n\ndef bad_response(data=None):\n    \"\"\"Prepare a bad response.\"\"\"\n    response = Mock()\n    response.content = json.dumps(\n        {\n            \"error\": \"InvalidRequest\",\n            \"message\": \"Something failed\",\n        }\n        if data is None\n        else data\n    )\n    response.headers = {}\n    response.status_code = requests.codes.internal_server_error\n    return response\n\n\n@pytest.fixture\ndef bluesky_url():\n    url = \"bluesky://user@app-key\"\n    return url\n\n\n@pytest.fixture\ndef good_message_response():\n    \"\"\"Prepare a good response.\"\"\"\n    response = good_response()\n    return response\n\n\n@pytest.fixture\ndef bad_message_response():\n    \"\"\"Prepare a bad message response.\"\"\"\n    response = bad_response()\n    return response\n\n\n@pytest.fixture\ndef good_media_response():\n    \"\"\"Prepare a good media response.\"\"\"\n    response = Mock()\n    response.content = json.dumps({\n        \"blob\": {\n            \"$type\": \"blob\",\n            \"mimeType\": \"image/jpeg\",\n            \"ref\": {\"$link\": \"baf124idksduabcjkaa3iey4bfyq\"},\n            \"size\": 73667,\n        }\n    })\n    response.headers = {}\n    response.status_code = requests.codes.ok\n    return response\n\n\ndef test_plugin_bluesky_urls():\n    \"\"\"NotifyBlueSky() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_bluesky_general(mocker):\n    \"\"\"NotifyBlueSky() General Tests.\"\"\"\n\n    mock_get = mocker.patch(\"requests.get\")\n    mock_post = mocker.patch(\"requests.post\")\n\n    # Epoch time:\n    epoch = datetime.fromtimestamp(0, timezone.utc)\n\n    request = good_response()\n    request.headers = {\n        \"ratelimit-reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"ratelimit-remaining\": \"1\",\n    }\n\n    # Prepare Mock\n    mock_get.return_value = request\n    mock_post.return_value = request\n\n    # Variation Initializations\n    obj = NotifyBlueSky(user=\"handle\", password=\"app-password\")\n\n    assert isinstance(obj, NotifyBlueSky) is True\n    assert isinstance(obj.url(), str) is True\n\n    # apprise room was found\n    assert obj.send(body=\"test\") is True\n\n    # Change our status code and try again\n    request.status_code = 403\n    assert obj.send(body=\"test\") is False\n    assert obj.ratelimit_remaining == 1\n\n    # Return the status\n    request.status_code = requests.codes.ok\n    # Force a reset\n    request.headers[\"ratelimit-remaining\"] = 0\n    # behind the scenes, it should cause us to update our rate limit\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n\n    # This should cause us to block\n    request.headers[\"ratelimit-remaining\"] = 10\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 10\n\n    # Handle cases where we simply couldn't get this field\n    del request.headers[\"ratelimit-remaining\"]\n    assert obj.send(body=\"test\") is True\n    # It remains set to the last value\n    assert obj.ratelimit_remaining == 10\n\n    # Reset our variable back to 1\n    request.headers[\"ratelimit-remaining\"] = 1\n\n    # Handle cases where our epoch time is wrong\n    del request.headers[\"ratelimit-reset\"]\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    request.headers[\"ratelimit-reset\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds() + 1\n    request.headers[\"ratelimit-remaining\"] = 0\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    request.headers[\"ratelimit-reset\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds() - 1\n    request.headers[\"ratelimit-remaining\"] = 0\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Return our limits to always work\n    request.headers[\"ratelimit-reset\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds()\n    request.headers[\"ratelimit-remaining\"] = 1\n    obj.ratelimit_remaining = 1\n\n    assert obj.send(body=\"test\") is True\n\n    # Flush our cache forcing it is re-creating\n    NotifyBlueSky._user_cache = {}\n    assert obj.send(body=\"test\") is True\n\n    # Cause content response to be None\n    request.content = None\n    assert obj.send(body=\"test\") is True\n\n    # Invalid JSON\n    request.content = \"{\"\n    assert obj.send(body=\"test\") is True\n\n    # Return it to a parseable string\n    request.content = \"{}\"\n\n    results = NotifyBlueSky.parse_url(\"bluesky://handle@app-pass-word\")\n    assert isinstance(results, dict) is True\n\n    # cause a json parsing issue now\n    response_obj = None\n    assert obj.send(body=\"test\") is True\n\n    response_obj = \"{\"\n    assert obj.send(body=\"test\") is True\n\n    # Flush out our cache\n    NotifyBlueSky._user_cache = {}\n\n    response_obj = {\n        \"accessJwt\": \"abcd\",\n        \"refreshJwt\": \"abcd\",\n        \"did\": \"did:plc:1234\",\n        # Support plc response\n        \"service\": [{\n            \"type\": \"AtprotoPersonalDataServer\",\n            \"serviceEndpoint\": \"https://example.pds.io\",\n        }],\n    }\n    request.content = json.dumps(response_obj)\n\n    obj = NotifyBlueSky(user=\"handle\", password=\"app-pass-word\")\n    assert obj.send(body=\"test\") is True\n\n    # Alter the key forcing us to look up a new value of ourselves again\n    NotifyBlueSky._user_cache = {}\n    NotifyBlueSky._whoami_cache = None\n    obj.ckey = \"different.then.it.was\"\n    assert obj.send(body=\"test\") is True\n\n    NotifyBlueSky._whoami_cache = None\n    obj.ckey = \"different.again\"\n    assert obj.send(body=\"test\") is True\n\n\ndef test_plugin_bluesky_edge_cases():\n    \"\"\"NotifyBlueSky() Edge Cases.\"\"\"\n\n    with pytest.raises(TypeError):\n        NotifyBlueSky()\n\n\n@patch(\"requests.post\")\n@patch(\"requests.get\")\ndef test_plugin_bluesky_attachments_basic(\n    mock_get,\n    mock_post,\n    bluesky_url,\n    good_message_response,\n    good_media_response,\n):\n    \"\"\"\n    NotifyBlueSky() Attachment Checks - Basic\n    \"\"\"\n\n    mock_get.return_value = good_message_response\n    mock_post.side_effect = [\n        good_message_response,\n        good_media_response,\n        good_message_response,\n    ]\n\n    # Create application objects.\n    obj = Apprise.instantiate(bluesky_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Verify API calls.\n    assert mock_get.call_count == 2\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle\"\n    )\n    assert (\n        mock_get.call_args_list[1][0][0]\n        == \"https://plc.directory/did:plc:1234\"\n    )\n\n    assert mock_post.call_count == 3\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.server.createSession\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n\n\n@patch(\"requests.post\")\n@patch(\"requests.get\")\ndef test_plugin_bluesky_attachments_bad_message_response(\n    mock_get,\n    mock_post,\n    bluesky_url,\n    good_media_response,\n    good_message_response,\n    bad_message_response,\n):\n\n    mock_get.return_value = good_message_response\n    mock_post.side_effect = [\n        good_message_response,\n        bad_message_response,\n        good_message_response,\n    ]\n\n    # Create application objects.\n    obj = Apprise.instantiate(bluesky_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Our notification will fail now since our message will error out.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Verify API calls.\n    assert mock_get.call_count == 2\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle\"\n    )\n    assert (\n        mock_get.call_args_list[1][0][0]\n        == \"https://plc.directory/did:plc:1234\"\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.server.createSession\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n\n\n@patch(\"requests.post\")\n@patch(\"requests.get\")\ndef test_plugin_bluesky_attachments_upload_fails(\n    mock_get,\n    mock_post,\n    bluesky_url,\n    good_media_response,\n    good_message_response,\n):\n\n    # Test case where upload fails.\n    mock_get.return_value = good_message_response\n    mock_post.side_effect = [good_message_response, OSError]\n\n    # Create application objects.\n    obj = Apprise.instantiate(bluesky_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification; it will fail because of the message response.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Verify API calls.\n    assert mock_get.call_count == 2\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle\"\n    )\n    assert (\n        mock_get.call_args_list[1][0][0]\n        == \"https://plc.directory/did:plc:1234\"\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.server.createSession\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n\n\n@patch(\"requests.post\")\n@patch(\"requests.get\")\ndef test_plugin_bluesky_attachments_invalid_attachment(\n    mock_get,\n    mock_post,\n    bluesky_url,\n    good_message_response,\n    good_media_response,\n):\n\n    mock_get.return_value = good_message_response\n    mock_post.side_effect = [good_message_response, good_media_response]\n\n    # Create application objects.\n    obj = Apprise.instantiate(bluesky_url)\n    attach = AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    )\n\n    # An invalid attachment will cause a failure.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Verify API calls.\n    assert mock_get.call_count == 2\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle\"\n    )\n    assert (\n        mock_get.call_args_list[1][0][0]\n        == \"https://plc.directory/did:plc:1234\"\n    )\n\n    # No post request as attachment is not good.\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.server.createSession\"\n    )\n\n\n@patch(\"requests.post\")\n@patch(\"requests.get\")\ndef test_plugin_bluesky_attachments_multiple_batch(\n    mock_get,\n    mock_post,\n    bluesky_url,\n    good_message_response,\n    good_media_response,\n):\n\n    mock_get.return_value = good_message_response\n    mock_post.side_effect = [\n        good_message_response,\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n    ]\n\n    # instantiate our object\n    obj = Apprise.instantiate(bluesky_url)\n\n    # 4 images are produced\n    attach = [\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.jpeg\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.png\"),\n        # This one is not supported, so it's ignored gracefully\n        os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\"),\n    ]\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Verify API calls.\n    assert mock_get.call_count == 2\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle\"\n    )\n    assert (\n        mock_get.call_args_list[1][0][0]\n        == \"https://plc.directory/did:plc:1234\"\n    )\n    assert mock_post.call_count == 9\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.server.createSession\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[5][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n    assert (\n        mock_post.call_args_list[6][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n    assert (\n        mock_post.call_args_list[7][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n    assert (\n        mock_post.call_args_list[8][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n\n    # If we call the functions again, the only difference is\n    # we no longer need to resolve the handle or create a session\n    # as the previous one is fine.\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    mock_get.return_value = good_message_response\n    mock_post.side_effect = [\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n    ]\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Verify API calls.\n    assert mock_get.call_count == 0\n    assert mock_post.call_count == 8\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.uploadBlob\"\n    )\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n    assert (\n        mock_post.call_args_list[5][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n    assert (\n        mock_post.call_args_list[6][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n    assert (\n        mock_post.call_args_list[7][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.repo.createRecord\"\n    )\n\n\n@patch(\"requests.post\")\n@patch(\"requests.get\")\ndef test_plugin_bluesky_auth_failure(\n    mock_get,\n    mock_post,\n    bluesky_url,\n    good_message_response,\n    bad_message_response,\n):\n\n    mock_get.return_value = good_message_response\n    mock_post.return_value = bad_message_response\n\n    # instantiate our object\n    obj = Apprise.instantiate(bluesky_url)\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n    # Verify API calls.\n    assert mock_get.call_count == 2\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle\"\n    )\n    assert (\n        mock_get.call_args_list[1][0][0]\n        == \"https://plc.directory/did:plc:1234\"\n    )\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://example.pds.io/xrpc/com.atproto.server.createSession\"\n    )\n\n\n@patch(\"requests.post\")\n@patch(\"requests.get\")\ndef test_plugin_bluesky_did_web_and_plc_resolution(\n    mock_get, mock_post, bluesky_url, good_message_response\n):\n    \"\"\"\n    NotifyBlueSky() - Full coverage of did:web and did:plc path\n    \"\"\"\n\n    # Step 1: Identity resolution response (public.api.bsky.app)\n    identity_response = good_response({\"did\": \"did:plc:abcdefg1234567\"})\n\n    # Step 2: PLC Directory lookup\n    plc_response = good_response({\n        \"service\": [{\n            \"type\": \"AtprotoPersonalDataServer\",\n            \"serviceEndpoint\": \"https://example.pds.io\",\n        }]\n    })\n\n    # Step 3: Auth session\n    session_response = good_response()\n\n    # Step 4: Create post\n    post_response = good_response()\n\n    mock_get.side_effect = [identity_response, plc_response]\n    mock_post.side_effect = [session_response, post_response]\n\n    obj = Apprise.instantiate(bluesky_url)\n    assert obj.notify(body=\"Resolved PLC Flow\") is True\n\n    # Reset for did:web test\n    identity_response = good_response({\"did\": \"did:web:example.com\"})\n\n    web_did_response = good_response({\n        \"service\": [{\n            \"type\": \"AtprotoPersonalDataServer\",\n            \"serviceEndpoint\": \"https://example.com\",\n        }]\n    })\n\n    mock_get.side_effect = [identity_response, web_did_response]\n    mock_post.side_effect = [session_response, post_response]\n\n    obj = Apprise.instantiate(bluesky_url)\n    assert obj.notify(body=\"Resolved WEB Flow\") is True\n\n    # Invalid DID scheme\n    bad_did_response = good_response({\"did\": \"did:unsupported:scheme\"})\n\n    mock_get.side_effect = [bad_did_response]\n    obj = Apprise.instantiate(bluesky_url)\n    assert obj.notify(body=\"fail due to bad scheme\") is False\n\n\n@patch(\"requests.get\")\ndef test_plugin_bluesky_pds_resolution_failures(mock_get):\n    \"\"\"\n    NotifyBlueSky() - Missing service field or invalid service endpoint\n    \"\"\"\n    identity_response = good_response({\"did\": \"did:plc:missing-service\"})\n    plc_no_service = good_response({\"foo\": \"bar\"})\n\n    mock_get.side_effect = [identity_response, plc_no_service]\n    obj = NotifyBlueSky(user=\"handle\", password=\"pass\")\n    did, endpoint = obj.get_identifier()\n    assert (did, endpoint) == (False, False)\n\n    identity_response = good_response({\"did\": \"did:web:example.com\"})\n    web_did_no_service = good_response({\"foo\": \"bar\"})\n\n    mock_get.side_effect = [identity_response, web_did_no_service]\n    obj = NotifyBlueSky(user=\"handle\", password=\"pass\")\n    did, endpoint = obj.get_identifier()\n    assert (did, endpoint) == (False, False)\n\n\n@patch(\"requests.get\")\ndef test_plugin_bluesky_missing_pds_endpoint(mock_get):\n    \"\"\"\n    NotifyBlueSky() - test case where endpoint is missing from DID document\n    \"\"\"\n    # Return a valid DID resolution\n    identity_response = good_response({\"did\": \"did:plc:abcdefg1234567\"})\n\n    # Return DID document with a service list, but no matching PDS type\n    incomplete_pds_response = good_response({\n        \"service\": [{\n            \"type\": \"SomeOtherService\",\n            \"serviceEndpoint\": \"https://unrelated.example.com\",\n        }]\n    })\n\n    mock_get.side_effect = [identity_response, incomplete_pds_response]\n    obj = NotifyBlueSky(user=\"handle\", password=\"app-pw\")\n    assert obj.get_identifier() == (False, False)\n"
  },
  {
    "path": "tests/test_plugin_brevo.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.brevo import NotifyBrevo\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# a test UUID we can use\nUUID4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"brevo://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"brevo://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"brevo://abcd\",\n        {\n            # Just an broken email (no api key or email)\n            \"instance\": None,\n        },\n    ),\n    (\n        \"brevo://abcd@host\",\n        {\n            # Just an Email specified, no API Key\n            \"instance\": None,\n        },\n    ),\n    (\n        \"brevo://invalid-api-key+*-d:user@example.com\",\n        {\n            # An invalid API Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        (\"brevo://abcd:user@example.com/newuser@example.com\"\n         \"?reply=%20!\"),\n        {\n            # An invalid Reply-To address\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"brevo://abcd:user@example.com?format=text\",\n        {\n            # No To/Target Address(es) specified; so we sub in the same From\n            # address\n            \"instance\": NotifyBrevo,\n        },\n    ),\n    (\n        (\"brevo://abcd:user@example.com/newuser@example.com\"\n         \"?reply=user@example.ca\"),\n        {\n            # A good email\n            \"instance\": NotifyBrevo,\n            \"force_debug\": True,\n        },\n    ),\n    (\n        \"brevo://abcd:user@example.com/bademailaddress\",\n        {\n            # won't be able to send email\n            \"instance\": NotifyBrevo,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        (\n            \"brevo://abcd:user@example.com/newuser@example.com\"\n            \"?bcc=l2g@nuxref.com\"\n        ),\n        {\n            # A good email with Blind Carbon Copy\n            \"instance\": NotifyBrevo,\n        },\n    ),\n    (\n        (\n            \"brevo://abcd:user@example.com/newuser@example.com\"\n            \"?cc=l2g@nuxref.com\"\n        ),\n        {\n            # A good email with Carbon Copy\n            \"instance\": NotifyBrevo,\n        },\n    ),\n    (\n        (\n            \"brevo://abcd:user@example.com/newuser@example.com\"\n            \"?to=l2g@nuxref.com\"\n        ),\n        {\n            # A good email with Carbon Copy\n            \"instance\": NotifyBrevo,\n        },\n    ),\n    (\n        \"brevo://abcd:user@example.ca/newuser@example.ca\",\n        {\n            \"instance\": NotifyBrevo,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"brevo://abcd:user@example.uk/newuser@example.uk\",\n        {\n            \"instance\": NotifyBrevo,\n            # throw a bizzare code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"brevo://abcd:user@example.au/newuser@example.au\",\n        {\n            \"instance\": NotifyBrevo,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracfully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_brevo_urls():\n    \"\"\"NotifyBrevo() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_brevo_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyBrevo() Edge Cases.\"\"\"\n\n    # no apikey\n    with pytest.raises(TypeError):\n        NotifyBrevo(apikey=None, from_email=\"user@example.com\")\n\n    # invalid from email\n    with pytest.raises(TypeError):\n        NotifyBrevo(apikey=\"abcd\", from_email=\"!invalid\")\n\n    # no email\n    with pytest.raises(TypeError):\n        NotifyBrevo(apikey=\"abcd\", from_email=None)\n\n    # Invalid To email address\n    NotifyBrevo(\n        apikey=\"abcd\", from_email=\"user@example.com\", targets=\"!invalid\"\n    )\n\n    # Test invalid bcc/cc entries mixed with good ones\n    assert isinstance(\n        NotifyBrevo(\n            apikey=\"abcd\",\n            from_email=\"l2g@example.com\",\n            bcc=(\"abc@def.com\", \"!invalid\"),\n            cc=(\"abc@test.org\", \"!invalid\"),\n        ),\n        NotifyBrevo,\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_brevo_attachments(mock_post):\n    \"\"\"NotifyBrevo() Attachments.\"\"\"\n\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = request\n\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    obj = Apprise.instantiate(\"brevo://abcd:user@example.com\")\n    assert isinstance(obj, NotifyBrevo)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    mock_post.reset_mock()\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n"
  },
  {
    "path": "tests/test_plugin_bulksms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.bulksms import NotifyBulkSMS\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"bulksms://\",\n        {\n            # Instantiated but no auth, so no otification can happen\n            \"instance\": NotifyBulkSMS,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"bulksms://:@/\",\n        {\n            # invalid auth\n            \"instance\": NotifyBulkSMS,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"bulksms://{}@12345678\".format(\"a\" * 10),\n        {\n            # Just user provided (no password)\n            \"instance\": NotifyBulkSMS,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"bulksms://{}:{}@{}\".format(\"a\" * 10, \"b\" * 10, \"3\" * 5),\n        {\n            # invalid nubmer provided\n            \"instance\": NotifyBulkSMS,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"bulksms://{}:{}@123/{}/abcd/\".format(\"a\" * 5, \"b\" * 10, \"3\" * 11),\n        {\n            # included group and phone, short number (123) dropped\n            \"instance\": NotifyBulkSMS,\n            \"privacy_url\": \"bulksms://a...a:****@+33333333333/@abcd\",\n        },\n    ),\n    (\n        \"bulksms://{}:{}@{}?batch=y&unicode=n\".format(\n            \"b\" * 5, \"c\" * 10, \"4\" * 11\n        ),\n        {\n            \"instance\": NotifyBulkSMS,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"bulksms://b...b:****@+4444444444\",\n        },\n    ),\n    (\n        \"bulksms://{}:{}@123456/{}\".format(\"a\" * 10, \"b\" * 10, \"4\" * 11),\n        {\n            # using short-code (6 characters)\n            \"instance\": NotifyBulkSMS,\n        },\n    ),\n    (\n        \"bulksms://{}:{}@{}\".format(\"a\" * 10, \"b\" * 10, \"5\" * 11),\n        {\n            # using phone no with no target - we text ourselves in\n            # this case\n            \"instance\": NotifyBulkSMS,\n        },\n    ),\n    # Test route group\n    (\n        \"bulksms://{}:{}@admin?route=premium\".format(\"a\" * 10, \"b\" * 10),\n        {\n            \"instance\": NotifyBulkSMS,\n        },\n    ),\n    (\n        \"bulksms://{}:{}@admin?route=invalid\".format(\"a\" * 10, \"b\" * 10),\n        {\n            # invalid route\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bulksms://_?user={}&password={}&from={}\".format(\n            \"a\" * 10, \"b\" * 10, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyBulkSMS,\n        },\n    ),\n    (\n        \"bulksms://_?user={}&password={}&from={}\".format(\n            \"a\" * 10, \"b\" * 10, \"5\" * 3\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bulksms://_?user={}&password={}&from={}&to={}\".format(\n            \"a\" * 10, \"b\" * 10, \"5\" * 11, \"7\" * 13\n        ),\n        {\n            # use to=\n            \"instance\": NotifyBulkSMS,\n        },\n    ),\n    (\n        \"bulksms://{}:{}@{}\".format(\"a\" * 10, \"b\" * 10, \"a\" * 3),\n        {\n            \"instance\": NotifyBulkSMS,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"bulksms://{}:{}@{}\".format(\"a\" * 10, \"b\" * 10, \"6\" * 11),\n        {\n            \"instance\": NotifyBulkSMS,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_bulksms_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_bulksms_edge_cases(mock_post):\n    \"\"\"NotifyBulkSMS() Edge Cases.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    user = \"abcd\"\n    pwd = \"mypass123\"\n    targets = [\n        \"+1(555) 123-1234\",\n        \"1555 5555555\",\n        \"group\",\n        # A garbage entry\n        \"12\",\n        # NOw a valid one because a group was implicit\n        \"@12\",\n    ]\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"bulksms://{}:{}@{}?batch=n\".format(user, pwd, \"/\".join(targets))\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # We know there are 4 targets\n    assert len(obj) == 4\n\n    # Test our call count\n    assert mock_post.call_count == 4\n\n    # Test\n    details = mock_post.call_args_list[0]\n    payload = loads(details[1][\"data\"])\n    assert payload[\"to\"] == \"+15551231234\"\n    assert payload[\"body\"] == \"title\\r\\nbody\"\n\n    details = mock_post.call_args_list[1]\n    payload = loads(details[1][\"data\"])\n    assert payload[\"to\"] == \"+15555555555\"\n    assert payload[\"body\"] == \"title\\r\\nbody\"\n\n    details = mock_post.call_args_list[2]\n    payload = loads(details[1][\"data\"])\n    assert isinstance(payload[\"to\"], dict)\n    assert payload[\"to\"][\"name\"] == \"group\"\n    assert payload[\"body\"] == \"title\\r\\nbody\"\n\n    details = mock_post.call_args_list[3]\n    payload = loads(details[1][\"data\"])\n    assert isinstance(payload[\"to\"], dict)\n    assert payload[\"to\"][\"name\"] == \"12\"\n    assert payload[\"body\"] == \"title\\r\\nbody\"\n\n    # Verify our URL looks good\n    assert obj.url().startswith(\n        \"bulksms://{}:{}@{}\".format(\n            user,\n            pwd,\n            \"/\".join([\"+15551231234\", \"+15555555555\", \"@group\", \"@12\"]),\n        )\n    )\n\n    assert \"batch=no\" in obj.url()\n\n    # With our batch in place, our calculations are different\n    obj = Apprise.instantiate(\n        \"bulksms://{}:{}@{}?batch=y\".format(user, pwd, \"/\".join(targets))\n    )\n    # 2 groups and 2 phones are lumped together\n    assert len(obj) == 3\n"
  },
  {
    "path": "tests/test_plugin_bulkvs.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.bulkvs import NotifyBulkVS\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"bulkvs://\",\n        {\n            # Instantiated but no auth, so no otification can happen\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bulkvs://:@/\",\n        {\n            # invalid auth\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bulkvs://{}@9876543210/\".format(\"a\" * 10),\n        {\n            # Just user provided (no password)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@{}\".format(\"u\" * 10, \"p\" * 10, \"3\" * 5),\n        {\n            # invalid source number provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@{}/{}\".format(\"u\" * 10, \"p\" * 10, \"1\" * 10, 2 * \"5\"),\n        {\n            # invalid target number provided\n            \"instance\": NotifyBulkVS,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@{}\".format(\"u\" * 10, \"p\" * 10, \"2\" * 10),\n        {\n            # default to ourselves\n            \"instance\": NotifyBulkVS,\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@9876543210/{}/abcd/\".format(\n            \"a\" * 5, \"b\" * 10, \"3\" * 11\n        ),\n        {\n            # included phone, short number (123) and garbage string (abcd)\n            # dropped\n            \"instance\": NotifyBulkVS,\n            \"privacy_url\": \"bulkvs://a...a:****@9876543210/33333333333\",\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@{}?batch=y\".format(\"b\" * 5, \"c\" * 10, \"4\" * 11),\n        {\n            \"instance\": NotifyBulkVS,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"bulkvs://b...b:****@44444444444\",\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@{}\".format(\"a\" * 10, \"b\" * 10, \"5\" * 11),\n        {\n            # using phone no with no target - we text ourselves in\n            # this case\n            \"instance\": NotifyBulkVS,\n        },\n    ),\n    (\n        \"bulkvs://?user={}&password={}&from={}\".format(\n            \"z\" * 10, \"y\" * 10, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyBulkVS,\n        },\n    ),\n    (\n        \"bulkvs://?user={}&password={}&from={}&to={}\".format(\n            \"a\" * 10, \"b\" * 10, \"5\" * 11, \"7\" * 13\n        ),\n        {\n            # use to=\n            \"instance\": NotifyBulkVS,\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@{}\".format(\"a\" * 10, \"b\" * 10, \"5\" * 11),\n        {\n            \"instance\": NotifyBulkVS,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"bulkvs://{}:{}@{}\".format(\"a\" * 10, \"b\" * 10, \"5\" * 11),\n        {\n            \"instance\": NotifyBulkVS,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_bulkvs_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_bulkvs_edge_cases(mock_post):\n    \"\"\"NotifyBulkVS() Edge Cases.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    user = \"abcd\"\n    pwd = \"mypass123\"\n    source = \"1 (405) 123 1234\"\n    targets = [\n        \"+1(555) 123-1234\",\n        \"1555 5555555\",\n        # A garbage entry\n        \"12\",\n        # NOw a valid one because a group was implicit\n        \"@12\",\n    ]\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"bulkvs://{}:{}@{}/{}?batch=n\".format(\n            user, pwd, source, \"/\".join(targets)\n        )\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # We know there are 2 targets\n    assert len(obj) == 2\n\n    # Test our call count\n    assert mock_post.call_count == 2\n\n    # Test\n    details = mock_post.call_args_list[0]\n    payload = loads(details[1][\"data\"])\n    assert payload[\"From\"] == \"14051231234\"\n    assert payload[\"To\"] == \"15551231234\"\n    assert payload[\"Message\"] == \"title\\r\\nbody\"\n\n    details = mock_post.call_args_list[1]\n    payload = loads(details[1][\"data\"])\n    assert payload[\"From\"] == \"14051231234\"\n    assert payload[\"To\"] == \"15555555555\"\n    assert payload[\"Message\"] == \"title\\r\\nbody\"\n\n    # Verify our URL looks good\n    assert obj.url().startswith(\n        \"bulkvs://abcd:mypass123@14051231234/15551231234/15555555555\"\n    )\n\n    assert \"batch=no\" in obj.url()\n\n    # With our batch in place, our calculations are different\n    obj = Apprise.instantiate(\n        \"bulkvs://{}:{}@{}?batch=y\".format(user, pwd, \"/\".join(targets))\n    )\n    # 2 phones are lumped together\n    assert len(obj) == 1\n"
  },
  {
    "path": "tests/test_plugin_burstsms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.burstsms import NotifyBurstSMS\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"burstsms://\",\n        {\n            # No API Key specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"burstsms://:@/\",\n        {\n            # invalid Auth key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"burstsms://{}@12345678\".format(\"a\" * 8),\n        {\n            # Just a key provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"burstsms://{}:{}@%20\".format(\"d\" * 8, \"e\" * 16),\n        {\n            # Invalid source number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"burstsms://{}:{}@{}/123/{}/abcd/\".format(\n            \"f\" * 8, \"g\" * 16, \"3\" * 11, \"9\" * 15\n        ),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifyBurstSMS,\n            # Expected notify() response because not all targets are valid\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"burstsms://f...f:****@\",\n        },\n    ),\n    (\n        \"burstsms://{}:{}@{}\".format(\"h\" * 8, \"i\" * 16, \"5\" * 11),\n        {\n            \"instance\": NotifyBurstSMS,\n            # Expected notify() response because no targets are defined\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"burstsms://_?key={}&secret={}&from={}&to={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyBurstSMS,\n        },\n    ),\n    (\n        \"burstsms://_?key={}&secret={}&from={}&to={}&batch=y\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            # batch flag set\n            \"instance\": NotifyBurstSMS,\n        },\n    ),\n    # Test our country\n    (\n        \"burstsms://_?key={}&secret={}&source={}&to={}&country=us\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            \"instance\": NotifyBurstSMS,\n        },\n    ),\n    # Test an invalid country\n    (\n        \"burstsms://_?key={}&secret={}&source={}&to={}&country=invalid\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Test our validity\n    (\n        \"burstsms://_?key={}&secret={}&source={}&to={}&validity=10\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            \"instance\": NotifyBurstSMS,\n        },\n    ),\n    # Test an invalid country\n    (\n        \"burstsms://_?key={}&secret={}&source={}&to={}&validity=invalid\"\n        .format(\"a\" * 8, \"b\" * 16, \"5\" * 11, \"6\" * 11),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"burstsms://_?key={}&secret={}&from={}&to={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"7\" * 11\n        ),\n        {\n            # use to=\n            \"instance\": NotifyBurstSMS,\n        },\n    ),\n    (\n        \"burstsms://{}:{}@{}/{}\".format(\"a\" * 8, \"b\" * 16, \"6\" * 11, \"7\" * 11),\n        {\n            \"instance\": NotifyBurstSMS,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"burstsms://{}:{}@{}/{}\".format(\"a\" * 8, \"b\" * 16, \"6\" * 11, \"7\" * 11),\n        {\n            \"instance\": NotifyBurstSMS,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_burstsms_urls():\n    \"\"\"NotifyBurstSMS() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_burstsms_edge_cases(mock_post):\n    \"\"\"NotifyBurstSMS() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    apikey = \"{}\".format(\"b\" * 8)\n    secret = \"{}\".format(\"b\" * 16)\n    source = \"+1 (555) 123-3456\"\n\n    # No apikey specified\n    with pytest.raises(TypeError):\n        NotifyBurstSMS(apikey=None, secret=secret, source=source)\n\n    with pytest.raises(TypeError):\n        NotifyBurstSMS(apikey=\"  \", secret=secret, source=source)\n\n    # No secret specified\n    with pytest.raises(TypeError):\n        NotifyBurstSMS(apikey=apikey, secret=None, source=source)\n\n    with pytest.raises(TypeError):\n        NotifyBurstSMS(apikey=apikey, secret=\"  \", source=source)\n\n    # a error response\n    response.status_code = 400\n    response.content = dumps({\n        \"error\": {\n            \"code\": \"FIELD_INVALID\",\n            \"description\": (\n                \"Sender ID must be one of the numbers that are currently\"\n                \" leased.\"\n            ),\n        },\n    })\n    mock_post.return_value = response\n\n    # Initialize our object\n    obj = NotifyBurstSMS(apikey=apikey, secret=secret, source=source)\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n"
  },
  {
    "path": "tests/test_plugin_chanify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.chanify import NotifyChanify\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"chanify://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"chanify://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"chanify://%badtoken%\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"chanify://abc123\",\n        {\n            # Test token\n            \"instance\": NotifyChanify,\n        },\n    ),\n    (\n        \"chanify://?token=abc123\",\n        {\n            # Test token\n            \"instance\": NotifyChanify,\n        },\n    ),\n    (\n        \"chanify://token\",\n        {\n            \"instance\": NotifyChanify,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"chanify://token\",\n        {\n            \"instance\": NotifyChanify,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"chanify://token\",\n        {\n            \"instance\": NotifyChanify,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_chanify_urls():\n    \"\"\"NotifyChanify() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_clickatell.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.clickatell import NotifyClickatell\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"clickatell://\",\n        {\n            # only schema provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"clickatell:///\",\n        {\n            # invalid apikey\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"clickatell://@/\",\n        {\n            # invalid apikey\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"clickatell://{}@/\".format(\"1\" * 10),\n        {\n            # no api key provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"clickatell://{}@{}/\".format(\"1\" * 3, \"a\" * 32),\n        {\n            # invalid From/Source\n            \"instance\": TypeError\n        },\n    ),\n    (\n        \"clickatell://{}/\".format(\"a\" * 32),\n        {\n            # no targets provided\n            \"instance\": NotifyClickatell,\n            # We have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"clickatell://{}@{}/\".format(\"1\" * 10, \"a\" * 32),\n        {\n            # no targets provided (no one to notify)\n            \"instance\": NotifyClickatell,\n            # We have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"clickatell://{}@{}/123/{}/abcd\".format(\"1\" * 10, \"a\" * 32, \"3\" * 15),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifyClickatell,\n            # We have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"clickatell://{}/{}\".format(\"1\" * 10, \"a\" * 32),\n        {\n            # everything valid (no source defined)\n            \"instance\": NotifyClickatell,\n            # We have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"clickatell://{}@{}/{}\".format(\"1\" * 10, \"a\" * 32, \"1\" * 10),\n        {\n            # everything valid\n            \"instance\": NotifyClickatell,\n        },\n    ),\n    (\n        \"clickatell://{}/{}\".format(\"a\" * 32, \"1\" * 10),\n        {\n            # everything valid (no source)\n            \"instance\": NotifyClickatell,\n        },\n    ),\n    (\n        \"clickatell://_?apikey={}&from={}&to={},{}\".format(\n            \"a\" * 32, \"1\" * 10, \"1\" * 10, \"1\" * 10\n        ),\n        {\n            # use get args to accomplish the same thing\n            \"instance\": NotifyClickatell,\n        },\n    ),\n    (\n        \"clickatell://_?apikey={}\".format(\"a\" * 32),\n        {\n            # use get args\n            \"instance\": NotifyClickatell,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"clickatell://_?apikey={}&from={}\".format(\"a\" * 32, \"1\" * 10),\n        {\n            # use get args\n            \"instance\": NotifyClickatell,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"clickatell://{}@{}/{}\".format(\"1\" * 10, \"a\" * 32, \"1\" * 10),\n        {\n            \"instance\": NotifyClickatell,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"clickatell://{}@{}/{}\".format(\"1\" * 10, \"a\" * 32, \"1\" * 10),\n        {\n            \"instance\": NotifyClickatell,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_clickatell_urls():\n    \"\"\"NotifyClickatell() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_clickatell_edge_cases(mock_post):\n    \"\"\"NotifyClickatell() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) apikeys\n    apikey = \"b\" * 32\n    from_phone = \"+1 (555) 123-3456\"\n\n    # No apikey specified\n    with pytest.raises(TypeError):\n        NotifyClickatell(apikey=None, from_phone=from_phone)\n\n    # a error response\n    response.status_code = 400\n    response.content = dumps({\n        \"code\": 21211,\n        \"message\": \"The 'To' number +1234567 is not a valid phone number.\",\n    })\n    mock_post.return_value = response\n\n    # Initialize our object\n    obj = NotifyClickatell(apikey=apikey, from_phone=from_phone)\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n"
  },
  {
    "path": "tests/test_plugin_clicksend.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.clicksend import NotifyClickSend\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"clicksend://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"clicksend://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"clicksend://user:pass@{}/{}/{}\".format(\"1\" * 9, \"2\" * 15, \"a\" * 13),\n        {\n            # invalid target numbers; we'll fail to notify anyone\n            \"instance\": NotifyClickSend,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"clicksend://user:pass@{}?batch=yes\".format(\"3\" * 14),\n        {\n            # valid number\n            \"instance\": NotifyClickSend,\n        },\n    ),\n    (\n        \"clicksend://user:pass@{}?batch=yes&to={}\".format(\"3\" * 14, \"6\" * 14),\n        {\n            # valid number but using the to= variable\n            \"instance\": NotifyClickSend,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"clicksend://user:****\",\n        },\n    ),\n    (\n        \"clicksend://user:pass@{}?batch=no\".format(\"3\" * 14),\n        {\n            # valid number - no batch\n            \"instance\": NotifyClickSend,\n        },\n    ),\n    (\n        \"clicksend://user@{}?batch=no&key=abc123\".format(\"3\" * 14),\n        {\n            # valid number - no batch\n            \"instance\": NotifyClickSend,\n        },\n    ),\n    (\n        \"clicksend://user:pass@{}\".format(\"3\" * 14),\n        {\n            \"instance\": NotifyClickSend,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"clicksend://user:pass@{}\".format(\"3\" * 14),\n        {\n            \"instance\": NotifyClickSend,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_clicksend_urls():\n    \"\"\"NotifyClickSend() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_custom_form.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.custom_form import NotifyForm\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"form://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"form://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"forms://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"form://localhost\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"form://user:pass@localhost\",\n        {\n            \"instance\": NotifyForm,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"form://user:****@localhost\",\n        },\n    ),\n    (\n        \"form://user@localhost\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    # Test method variations\n    (\n        \"form://user@localhost?method=put\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=get\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=post\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=head\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=delete\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=patch\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=update\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user@localhost?method=options\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    # Custom payload options\n    (\n        \"form://localhost:8080?:key=value&:key2=value2\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    # Continue testing other cases\n    (\n        \"form://localhost:8080\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"forms://localhost\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"forms://user:pass@localhost\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"forms://localhost:8080/path/\",\n        {\n            \"instance\": NotifyForm,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"forms://localhost:8080/path/\",\n        },\n    ),\n    (\n        \"forms://user:password@localhost:8080\",\n        {\n            \"instance\": NotifyForm,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"forms://user:****@localhost:8080\",\n        },\n    ),\n    # Test our GET params\n    (\n        \"form://localhost:8080/path?-ParamA=Value\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    # Test our Headers\n    (\n        \"form://localhost:8080/path?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifyForm,\n        },\n    ),\n    (\n        \"form://user:pass@localhost:8081\",\n        {\n            \"instance\": NotifyForm,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"form://user:pass@localhost:8082\",\n        {\n            \"instance\": NotifyForm,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"form://user:pass@localhost:8083\",\n        {\n            \"instance\": NotifyForm,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_custom_form_urls():\n    \"\"\"NotifyForm() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_custom_form_attachments(mock_request):\n    \"\"\"NotifyForm() Attachments.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n\n    # Assign our mock object our return value\n    mock_request.return_value = okay_response\n\n    obj = Apprise.instantiate(\"form://user@localhost.localdomain/?method=post\")\n    assert isinstance(obj, NotifyForm)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    mock_request.return_value = None\n    mock_request.side_effect = OSError()\n    # We can't send the message if we can't read the attachment\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Test Valid Attachment (load 3)\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n\n    # Return our good configuration\n    mock_request.side_effect = None\n    mock_request.return_value = okay_response\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # Fail on the 2nd attempt (but not the first)\n    with mock.patch(\"builtins.open\", side_effect=[None, OSError(), None]):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # Test file exception handling when performing post\n    mock_request.return_value = None\n    mock_request.side_effect = OSError()\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    #\n    # Test attach-as\n    #\n\n    # Assign our mock object our return value\n    mock_request.return_value = okay_response\n    mock_request.side_effect = None\n\n    obj = Apprise.instantiate(\n        \"form://user@localhost.localdomain/?attach-as=file\"\n    )\n    assert isinstance(obj, NotifyForm)\n\n    # Test Single Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test Valid Attachment (load 3) (produces a warning)\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our other variations of accepted values\n    # we support *, :, ?, ., +, %, and $\n    for attach_as in (\n        \"file*\",\n        \"*file\",\n        \"file*file\",\n        \"file:\",\n        \":file\",\n        \"file:file\",\n        \"file?\",\n        \"?file\",\n        \"file?file\",\n        \"file.\",\n        \".file\",\n        \"file.file\",\n        \"file+\",\n        \"+file\",\n        \"file+file\",\n        \"file$\",\n        \"$file\",\n        \"file$file\",\n    ):\n\n        obj = Apprise.instantiate(\n            f\"form://user@localhost.localdomain/?attach-as={attach_as}\"\n        )\n        assert isinstance(obj, NotifyForm)\n\n        # Test Single Valid Attachment\n        path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n        attach = AppriseAttachment(path)\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is True\n        )\n\n        # Test Valid Attachment (load 3) (produces a warning)\n        path = (\n            os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n            os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n            os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        )\n        attach = AppriseAttachment(path)\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is True\n        )\n\n    # Test invalid attach-as input\n    obj = Apprise.instantiate(\"form://user@localhost.localdomain/?attach-as={\")\n    assert obj is None\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_custom_form_edge_cases(mock_request):\n    \"\"\"NotifyForm() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = response\n\n    results = NotifyForm.parse_url(\n        \"form://localhost:8080/command?:message=msg&:abcd=test&method=POST\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] == 8080\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/command\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] == \"command\"\n    assert results[\"schema\"] == \"form\"\n    assert results[\"url\"] == \"form://localhost:8080/command\"\n    assert isinstance(results[\"qsd:\"], dict) is True\n    assert results[\"qsd:\"][\"abcd\"] == \"test\"\n    assert results[\"qsd:\"][\"message\"] == \"msg\"\n\n    instance = NotifyForm(**results)\n    assert isinstance(instance, NotifyForm)\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    assert details[0][1] == \"http://localhost:8080/command\"\n    assert \"abcd\" in details[1][\"data\"]\n    assert details[1][\"data\"][\"abcd\"] == \"test\"\n    assert \"title\" in details[1][\"data\"]\n    assert details[1][\"data\"][\"title\"] == \"title\"\n    assert \"message\" not in details[1][\"data\"]\n    # message over-ride was provided; the body is now in `msg` and not\n    # `message`\n    assert \"msg\" in details[1][\"data\"]\n    assert details[1][\"data\"][\"msg\"] == \"body\"\n    assert details[1][\"data\"][\"type\"] == NotifyType.INFO.value\n    assert \"NotifyType.\" not in str(details[1][\"data\"])\n\n    assert instance.url(privacy=False).startswith(\n        \"form://localhost:8080/command?\"\n    )\n\n    # Generate a new URL based on our last and verify key values are the same\n    new_results = NotifyForm.parse_url(instance.url(safe=False))\n    for k in (\n        \"user\",\n        \"password\",\n        \"port\",\n        \"host\",\n        \"fullpath\",\n        \"path\",\n        \"query\",\n        \"schema\",\n        \"url\",\n        \"payload\",\n        \"method\",\n    ):\n        assert new_results[k] == results[k]\n\n    # Reset our mock configuration\n    mock_request.reset_mock()\n\n    results = NotifyForm.parse_url(\n        \"form://localhost:8080/command?:type=&:message=msg&method=POST\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] == 8080\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/command\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] == \"command\"\n    assert results[\"schema\"] == \"form\"\n    assert results[\"url\"] == \"form://localhost:8080/command\"\n    assert isinstance(results[\"qsd:\"], dict) is True\n    assert results[\"qsd:\"][\"message\"] == \"msg\"\n\n    instance = NotifyForm(**results)\n    assert isinstance(instance, NotifyForm)\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    assert details[0][1] == \"http://localhost:8080/command\"\n    assert \"title\" in details[1][\"data\"]\n    assert details[1][\"data\"][\"title\"] == \"title\"\n\n    # type was removed from response object\n    assert \"type\" not in details[1][\"data\"]\n\n    # message over-ride was provided; the body is now in `msg` and not\n    # `message`\n    assert details[1][\"data\"][\"msg\"] == \"body\"\n\n    # 'body' is over-ridden by 'test' passed inline with the URL\n    assert \"message\" not in details[1][\"data\"]\n    assert \"msg\" in details[1][\"data\"]\n    assert details[1][\"data\"][\"msg\"] == \"body\"\n\n    assert instance.url(privacy=False).startswith(\n        \"form://localhost:8080/command?\"\n    )\n\n    # Generate a new URL based on our last and verify key values are the same\n    new_results = NotifyForm.parse_url(instance.url(safe=False))\n    for k in (\n        \"user\",\n        \"password\",\n        \"port\",\n        \"host\",\n        \"fullpath\",\n        \"path\",\n        \"query\",\n        \"schema\",\n        \"url\",\n        \"payload\",\n        \"method\",\n    ):\n        assert new_results[k] == results[k]\n\n    # Reset our mock configuration\n    mock_request.reset_mock()\n\n    results = NotifyForm.parse_url(\n        \"form://localhost:8080/command?:message=test&method=GET\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] == 8080\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/command\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] == \"command\"\n    assert results[\"schema\"] == \"form\"\n    assert results[\"url\"] == \"form://localhost:8080/command\"\n    assert isinstance(results[\"qsd:\"], dict) is True\n    assert results[\"qsd:\"][\"message\"] == \"test\"\n\n    instance = NotifyForm(**results)\n    assert isinstance(instance, NotifyForm)\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"GET\"\n    assert details[0][1] == \"http://localhost:8080/command\"\n\n    assert \"title\" in details[1][\"params\"]\n    assert details[1][\"params\"][\"title\"] == \"title\"\n    # 'body' is over-ridden by 'test' passed inline with the URL\n    assert \"message\" not in details[1][\"params\"]\n    assert \"test\" in details[1][\"params\"]\n    assert details[1][\"params\"][\"test\"] == \"body\"\n\n    assert instance.url(privacy=False).startswith(\n        \"form://localhost:8080/command?\"\n    )\n\n    # Generate a new URL based on our last and verify key values are the same\n    new_results = NotifyForm.parse_url(instance.url(safe=False))\n    for k in (\n        \"user\",\n        \"password\",\n        \"port\",\n        \"host\",\n        \"fullpath\",\n        \"path\",\n        \"query\",\n        \"schema\",\n        \"url\",\n        \"payload\",\n        \"method\",\n    ):\n        assert new_results[k] == results[k]\n\n    mock_request.reset_mock()\n\n    # Verify UPDATE method is forwarded correctly\n    results = NotifyForm.parse_url(\"form://localhost?method=UPDATE\")\n    instance = NotifyForm(**results)\n    assert instance.send(title=\"title\", body=\"body\") is True\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"UPDATE\"\n    assert details[0][1] == \"http://localhost\"\n\n    mock_request.reset_mock()\n\n    # Verify OPTIONS method is forwarded correctly\n    results = NotifyForm.parse_url(\"form://localhost?method=OPTIONS\")\n    instance = NotifyForm(**results)\n    assert instance.send(title=\"title\", body=\"body\") is True\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"OPTIONS\"\n    assert details[0][1] == \"http://localhost\"\n"
  },
  {
    "path": "tests/test_plugin_custom_json.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.custom_json import NotifyJSON\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"json://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"json://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"jsons://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"json://localhost\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user@localhost?method=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"json://user:pass@localhost\",\n        {\n            \"instance\": NotifyJSON,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"json://user:****@localhost\",\n        },\n    ),\n    (\n        \"json://user@localhost\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    # Test method variations\n    (\n        \"json://user@localhost?method=put\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user@localhost?method=get\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user@localhost?method=post\",\n        {\n            \"instance\": NotifyJSON,\n            \"force_debug\": True,\n        },\n    ),\n    (\n        \"json://user@localhost?method=head\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user@localhost?method=delete\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user@localhost?method=patch\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user@localhost?method=update\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user@localhost?method=options\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    # Continue testing other cases\n    (\n        \"json://localhost:8080\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"jsons://localhost\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"jsons://user:pass@localhost\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"jsons://localhost:8080/path/\",\n        {\n            \"instance\": NotifyJSON,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"jsons://localhost:8080/path/\",\n        },\n    ),\n    # Test our GET params\n    (\n        \"json://localhost:8080/path?-ParamA=Value\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"jsons://user:password@localhost:8080\",\n        {\n            \"instance\": NotifyJSON,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"jsons://user:****@localhost:8080\",\n        },\n    ),\n    # Test our Headers\n    (\n        \"json://localhost:8080/path?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifyJSON,\n        },\n    ),\n    (\n        \"json://user:pass@localhost:8081\",\n        {\n            \"instance\": NotifyJSON,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"json://user:pass@localhost:8082\",\n        {\n            \"instance\": NotifyJSON,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"json://user:pass@localhost:8083\",\n        {\n            \"instance\": NotifyJSON,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_custom_json_urls():\n    \"\"\"NotifyJSON() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_custom_json_edge_cases(mock_request):\n    \"\"\"NotifyJSON() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = response\n\n    # This string also tests that type is set to nothing\n    results = NotifyJSON.parse_url(\n        \"json://localhost:8080/command?\"\n        \":message=msg&:test=value&method=GET\"\n        \"&:type=\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] == 8080\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/command\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] == \"command\"\n    assert results[\"schema\"] == \"json\"\n    assert results[\"url\"] == \"json://localhost:8080/command\"\n    assert isinstance(results[\"qsd:\"], dict)\n    assert results[\"qsd:\"][\"message\"] == \"msg\"\n    # empty special mapping\n    assert results[\"qsd:\"][\"type\"] == \"\"\n\n    instance = NotifyJSON(**results)\n    assert isinstance(instance, NotifyJSON)\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"GET\"\n    assert details[0][1] == \"http://localhost:8080/command\"\n    assert \"title\" in details[1][\"data\"]\n    dataset = json.loads(details[1][\"data\"])\n    assert dataset[\"title\"] == \"title\"\n    assert \"message\" not in dataset\n    # Ensure NotifyType does not leak as an Enum string in JSON\n    assert \"NotifyType.\" not in details[1][\"data\"]\n    assert \"msg\" in dataset\n    # type was set to nothing which implies it should be removed\n    assert \"type\" not in dataset\n    # message over-ride was provided; the body is now in `msg` and not\n    # `message`\n    assert dataset[\"msg\"] == \"body\"\n\n    assert \"test\" in dataset\n    assert dataset[\"test\"] == \"value\"\n\n    assert instance.url(privacy=False).startswith(\n        \"json://localhost:8080/command?\"\n    )\n\n    # Generate a new URL based on our last and verify key values are the same\n    new_results = NotifyJSON.parse_url(instance.url(safe=False))\n    for k in (\n        \"user\",\n        \"password\",\n        \"port\",\n        \"host\",\n        \"fullpath\",\n        \"path\",\n        \"query\",\n        \"schema\",\n        \"url\",\n        \"method\",\n    ):\n        assert new_results[k] == results[k]\n\n    mock_request.reset_mock()\n\n    results = NotifyJSON.parse_url(\n        \"json://localhost:8080/command?:message=msg&method=GET\"\n    )\n    instance = NotifyJSON(**results)\n    assert instance.send(title=\"title\", body=\"body\") is True\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"GET\"\n    dataset = json.loads(details[1][\"data\"])\n\n    assert dataset[\"type\"] == NotifyType.INFO.value\n    assert \"NotifyType.\" not in details[1][\"data\"]\n\n    mock_request.reset_mock()\n\n    # Verify UPDATE method is forwarded correctly\n    results = NotifyJSON.parse_url(\"json://localhost?method=UPDATE\")\n    instance = NotifyJSON(**results)\n    assert instance.send(title=\"title\", body=\"body\") is True\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"UPDATE\"\n    assert details[0][1] == \"http://localhost\"\n\n    mock_request.reset_mock()\n\n    # Verify OPTIONS method is forwarded correctly\n    results = NotifyJSON.parse_url(\"json://localhost?method=OPTIONS\")\n    instance = NotifyJSON(**results)\n    assert instance.send(title=\"title\", body=\"body\") is True\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"OPTIONS\"\n    assert details[0][1] == \"http://localhost\"\n\n\n@mock.patch(\"requests.request\")\ndef test_notify_json_plugin_attachments(mock_request):\n    \"\"\"NotifyJSON() Attachments.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n\n    # Assign our mock object our return value\n    mock_request.return_value = okay_response\n\n    obj = Apprise.instantiate(\"json://localhost.localdomain/\")\n    assert isinstance(obj, NotifyJSON)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # Test Valid Attachment (load 3)\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n\n    # Return our good configuration\n    mock_request.side_effect = None\n    mock_request.return_value = okay_response\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # test the handling of our batch modes\n    obj = Apprise.instantiate(\"json://no-reply@example.com/\")\n    assert isinstance(obj, NotifyJSON)\n\n    # Now send an attachment normally without issues\n    mock_request.reset_mock()\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_request.call_count == 1\n\n\n# Based on incomming webhook details defined here:\n# https://kb.synology.com/en-au/DSM/help/Chat/chat_integration\n@mock.patch(\"requests.request\")\ndef test_plugin_custom_form_for_synology(mock_request):\n    \"\"\"NotifyJSON() Synology Chat Test Case.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = response\n\n    # This is rather confusing, it may be easier to leverage the\n    # synology:// and synologys:// plugins instead, but this is just to prove\n    # that the same message can be sent using the json:// plugin.\n\n    results = NotifyJSON.parse_url(\n        \"jsons://localhost:8081/webapi/entry.cgi?\"\n        \"-api=SYNO.Chat.External&-method=incoming&-version=2&-token=abc123\"\n        \"&:message=text&:version=&:type=&:title=&:attachments\"\n        \"&:file_url=https://i.redd.it/my2t4d2fx0u31.jpg\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] == 8081\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/webapi/entry.cgi\"\n    assert results[\"path\"] == \"/webapi/\"\n    assert results[\"query\"] == \"entry.cgi\"\n    assert results[\"schema\"] == \"jsons\"\n    assert results[\"url\"] == \"jsons://localhost:8081/webapi/entry.cgi\"\n    assert isinstance(results[\"qsd:\"], dict)\n    # Header Entries\n    assert results[\"qsd-\"][\"api\"] == \"SYNO.Chat.External\"\n    assert results[\"qsd-\"][\"method\"] == \"incoming\"\n    assert results[\"qsd-\"][\"version\"] == \"2\"\n    assert results[\"qsd-\"][\"token\"] == \"abc123\"\n\n    instance = NotifyJSON(**results)\n    assert isinstance(instance, NotifyJSON)\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    assert details[0][1] == \"https://localhost:8081/webapi/entry.cgi\"\n\n    params = details[1][\"params\"]\n    assert params.get(\"api\") == \"SYNO.Chat.External\"\n    assert params.get(\"method\") == \"incoming\"\n    assert params.get(\"version\") == \"2\"\n    assert params.get(\"token\") == \"abc123\"\n\n    payload = json.loads(details[1][\"data\"])\n    assert \"version\" not in payload\n    assert \"title\" not in payload\n    assert \"message\" not in payload\n    assert \"type\" not in payload\n    assert payload.get(\"text\") == \"body\"\n    assert payload.get(\"file_url\") == \"https://i.redd.it/my2t4d2fx0u31.jpg\"\n"
  },
  {
    "path": "tests/test_plugin_custom_xml.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport re\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.custom_xml import NotifyXML\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"xml://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"xml://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"xmls://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"xml://localhost\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"xml://user:pass@localhost\",\n        {\n            \"instance\": NotifyXML,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"xml://user:****@localhost\",\n        },\n    ),\n    # Test method variations\n    (\n        \"xml://user@localhost?method=put\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=get\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=post\",\n        {\n            \"instance\": NotifyXML,\n            \"force_debug\": True,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=head\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=delete\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=patch\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=update\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user@localhost?method=options\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    # Continue testing other cases\n    (\n        \"xml://localhost:8080\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xmls://localhost\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xmls://user:pass@localhost\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    # Continue testing other cases\n    (\n        \"xml://localhost:8080\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://localhost\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xmls://user:pass@localhost\",\n        {\n            \"instance\": NotifyXML,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"xmls://user:****@localhost\",\n        },\n    ),\n    (\n        \"xml://user@localhost:8080/path/\",\n        {\n            \"instance\": NotifyXML,\n            \"privacy_url\": \"xml://user@localhost:8080/path\",\n        },\n    ),\n    (\n        \"xmls://localhost:8080/path/\",\n        {\n            \"instance\": NotifyXML,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"xmls://localhost:8080/path/\",\n        },\n    ),\n    (\n        \"xmls://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    # Test our GET params\n    (\n        \"xml://localhost:8080/path?-ParamA=Value\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    # Test our Headers\n    (\n        \"xml://localhost:8080/path?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifyXML,\n        },\n    ),\n    (\n        \"xml://user:pass@localhost:8081\",\n        {\n            \"instance\": NotifyXML,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"xml://user:pass@localhost:8082\",\n        {\n            \"instance\": NotifyXML,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"xml://user:pass@localhost:8083\",\n        {\n            \"instance\": NotifyXML,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_custom_xml_urls():\n    \"\"\"NotifyXML() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.request\")\ndef test_notify_xml_plugin_attachments(mock_request):\n    \"\"\"NotifyXML() Attachments.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n\n    # Assign our mock object our return value\n    mock_request.return_value = okay_response\n\n    obj = Apprise.instantiate(\"xml://localhost.localdomain/\")\n    assert isinstance(obj, NotifyXML)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # Test Valid Attachment (load 3)\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n\n    # Return our good configuration\n    mock_request.side_effect = None\n    mock_request.return_value = okay_response\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # test the handling of our batch modes\n    obj = Apprise.instantiate(\"xml://no-reply@example.com/\")\n    assert isinstance(obj, NotifyXML)\n\n    # Now send an attachment normally without issues\n    mock_request.reset_mock()\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_request.call_count == 1\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_custom_xml_edge_cases(mock_request):\n    \"\"\"NotifyXML() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = response\n\n    results = NotifyXML.parse_url(\n        \"xml://localhost:8080/command?:Message=Body&method=GET\"\n        \"&:Key=value&:,=invalid&:MessageType=\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] == 8080\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/command\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] == \"command\"\n    assert results[\"schema\"] == \"xml\"\n    assert results[\"url\"] == \"xml://localhost:8080/command\"\n    assert isinstance(results[\"qsd:\"], dict)\n    assert results[\"qsd:\"][\"Message\"] == \"Body\"\n    assert results[\"qsd:\"][\"Key\"] == \"value\"\n    assert results[\"qsd:\"][\",\"] == \"invalid\"\n\n    instance = NotifyXML(**results)\n    assert isinstance(instance, NotifyXML)\n\n    # XSD URL is disabled due to custom formatting\n    assert instance.xsd_url is None\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert \"NotifyType.\" not in details[1][\"data\"]\n    assert details[0][0] == \"GET\"\n    assert details[0][1] == \"http://localhost:8080/command\"\n    assert instance.url(privacy=False).startswith(\n        \"xml://localhost:8080/command?\"\n    )\n\n    # Generate a new URL based on our last and verify key values are the same\n    new_results = NotifyXML.parse_url(instance.url(safe=False))\n    for k in (\n        \"user\",\n        \"password\",\n        \"port\",\n        \"host\",\n        \"fullpath\",\n        \"path\",\n        \"query\",\n        \"schema\",\n        \"url\",\n        \"method\",\n    ):\n        assert new_results[k] == results[k]\n\n    # Test our data set for our key/value pair\n    assert re.search(r\"<Version>[1-9]+\\.[0-9]+</Version>\", details[1][\"data\"])\n    assert \"<Subject>title</Subject>\" in details[1][\"data\"]\n\n    assert \"<Message>test</Message>\" not in details[1][\"data\"]\n    assert \"<Message>\" not in details[1][\"data\"]\n    # MessageType was removed from the payload\n    assert \"<MessageType>\" not in details[1][\"data\"]\n    # However we can find our mapped Message to the new value Body\n    assert \"<Body>body</Body>\" in details[1][\"data\"]\n    # Custom entry\n    assert \"<Key>value</Key>\" in details[1][\"data\"]\n\n    mock_request.reset_mock()\n\n    results = NotifyXML.parse_url(\n        \"xml://localhost:8081/command?method=POST&:New=Value\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] == 8081\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/command\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] == \"command\"\n    assert results[\"schema\"] == \"xml\"\n    assert results[\"url\"] == \"xml://localhost:8081/command\"\n    assert isinstance(results[\"qsd:\"], dict)\n    assert results[\"qsd:\"][\"New\"] == \"Value\"\n\n    instance = NotifyXML(**results)\n    assert isinstance(instance, NotifyXML)\n\n    # XSD URL is disabled due to custom formatting\n    assert instance.xsd_url is None\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert \"NotifyType.\" not in details[1][\"data\"]\n    assert details[0][0] == \"POST\"\n    assert details[0][1] == \"http://localhost:8081/command\"\n    assert instance.url(privacy=False).startswith(\n        \"xml://localhost:8081/command?\"\n    )\n\n    # Generate a new URL based on our last and verify key values are the same\n    new_results = NotifyXML.parse_url(instance.url(safe=False))\n    for k in (\n        \"user\",\n        \"password\",\n        \"port\",\n        \"host\",\n        \"fullpath\",\n        \"path\",\n        \"query\",\n        \"schema\",\n        \"url\",\n        \"method\",\n    ):\n        assert new_results[k] == results[k]\n\n    # Test our data set for our key/value pair\n    assert re.search(r\"<Version>[1-9]+\\.[0-9]+</Version>\", details[1][\"data\"])\n    assert re.search(r\"<MessageType>{}</MessageType>\".format(\n        NotifyType.INFO.value), details[1][\"data\"])\n    assert r\"<Subject>title</Subject>\" in details[1][\"data\"]\n    # No over-ride\n    assert r\"<Message>body</Message>\" in details[1][\"data\"]\n\n    mock_request.reset_mock()\n\n    results = NotifyXML.parse_url(\n        \"xmls://localhost?method=POST&:Message=Body&:Subject=Title&:Version\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] is None\n    assert results[\"path\"] is None\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"xmls\"\n    assert results[\"url\"] == \"xmls://localhost\"\n    assert isinstance(results[\"qsd:\"], dict)\n    assert results[\"qsd:\"][\"Version\"] == \"\"\n    assert results[\"qsd:\"][\"Message\"] == \"Body\"\n    assert results[\"qsd:\"][\"Subject\"] == \"Title\"\n\n    instance = NotifyXML(**results)\n    assert isinstance(instance, NotifyXML)\n\n    # XSD URL is disabled due to custom formatting\n    assert instance.xsd_url is None\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_request.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert \"NotifyType.\" not in details[1][\"data\"]\n    assert details[0][0] == \"POST\"\n    assert details[0][1] == \"https://localhost\"\n    assert instance.url(privacy=False).startswith(\"xmls://localhost\")\n\n    # Generate a new URL based on our last and verify key values are the same\n    new_results = NotifyXML.parse_url(instance.url(safe=False))\n\n    # Test that the Version has been dropped\n    assert (\n        re.search(r\"<Version>[1-9]+\\.[0-9]+</Version>\", details[1][\"data\"])\n        is None\n    )\n\n    # Test our data set for our key/value pair\n    assert re.search(r\"<MessageType>{}</MessageType>\".format(\n        NotifyType.INFO.value), details[1][\"data\"])\n\n    # Subject is swapped for Title\n    assert r\"<Subject>title</Subject>\" not in details[1][\"data\"]\n    assert r\"<Title>title</Title>\" in details[1][\"data\"]\n\n    # Message is swapped for Body\n    assert r\"<Message>body</Message>\" not in details[1][\"data\"]\n    assert r\"<Body>body</Body>\" in details[1][\"data\"]\n\n    mock_request.reset_mock()\n\n    # Verify UPDATE method is forwarded correctly\n    results = NotifyXML.parse_url(\"xml://localhost?method=UPDATE\")\n    instance = NotifyXML(**results)\n    assert instance.send(title=\"title\", body=\"body\") is True\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"UPDATE\"\n    assert details[0][1] == \"http://localhost\"\n\n    mock_request.reset_mock()\n\n    # Verify OPTIONS method is forwarded correctly\n    results = NotifyXML.parse_url(\"xml://localhost?method=OPTIONS\")\n    instance = NotifyXML(**results)\n    assert instance.send(title=\"title\", body=\"body\") is True\n    assert mock_request.call_count == 1\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"OPTIONS\"\n    assert details[0][1] == \"http://localhost\"\n"
  },
  {
    "path": "tests/test_plugin_d7networks.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.d7networks import NotifyD7Networks\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"d7sms://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"d7sms://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"d7sms://token@{}/{}/{}\".format(\"1\" * 9, \"2\" * 15, \"a\" * 13),\n        {\n            # No valid targets to notify\n            \"instance\": NotifyD7Networks,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"d7sms://token1@{}?batch=yes\".format(\"3\" * 14),\n        {\n            # valid number\n            \"instance\": NotifyD7Networks,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"d7sms://t...1@\",\n        },\n    ),\n    (\n        \"d7sms://token:colon2@{}?batch=yes\".format(\"3\" * 14),\n        {\n            # valid number - token containing a colon\n            \"instance\": NotifyD7Networks,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"d7sms://t...2@\",\n        },\n    ),\n    (\n        \"d7sms://:token3@{}?batch=yes\".format(\"3\" * 14),\n        {\n            # valid number - token starting wit a colon\n            \"instance\": NotifyD7Networks,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"d7sms://:...3@\",\n        },\n    ),\n    (\n        \"d7sms://{}?token=token6\".format(\"3\" * 14),\n        {\n            # valid number - token starting wit a colon\n            \"instance\": NotifyD7Networks,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"d7sms://t...6@\",\n        },\n    ),\n    (\n        \"d7sms://token4@{}?unicode=no\".format(\"3\" * 14),\n        {\n            # valid number - test unicode\n            \"instance\": NotifyD7Networks,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"d7sms://t...4@\",\n        },\n    ),\n    (\n        \"d7sms://token8@{}/{}/?unicode=yes\".format(\"3\" * 14, \"4\" * 14),\n        {\n            # valid number - test unicode\n            \"instance\": NotifyD7Networks,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"d7sms://t...8@\",\n        },\n    ),\n    (\n        \"d7sms://token@{}?batch=yes&to={}\".format(\"3\" * 14, \"6\" * 14),\n        {\n            # valid number\n            \"instance\": NotifyD7Networks,\n        },\n    ),\n    (\n        \"d7sms://token@{}?batch=yes&from=apprise\".format(\"3\" * 14),\n        {\n            # valid number, utilizing the optional from= variable\n            \"instance\": NotifyD7Networks,\n        },\n    ),\n    (\n        \"d7sms://token@{}?batch=yes&source=apprise\".format(\"3\" * 14),\n        {\n            # valid number, utilizing the optional source= variable (same as\n            # from)\n            \"instance\": NotifyD7Networks,\n        },\n    ),\n    (\n        \"d7sms://token@{}?batch=no\".format(\"3\" * 14),\n        {\n            # valid number - no batch\n            \"instance\": NotifyD7Networks,\n        },\n    ),\n    (\n        \"d7sms://token@{}\".format(\"3\" * 14),\n        {\n            \"instance\": NotifyD7Networks,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"d7sms://token@{}\".format(\"3\" * 14),\n        {\n            \"instance\": NotifyD7Networks,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_d7networks_urls():\n    \"\"\"NotifyD7Networks() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_d7networks_edge_cases(mock_post):\n    \"\"\"NotifyD7Networks() Edge Cases tests.\"\"\"\n\n    # Prepare Mock\n    request = mock.Mock()\n    request.content = \"{}\"\n    request.status_code = requests.codes.ok\n    mock_post.return_value = request\n\n    # Initializations\n    aobj = Apprise()\n    assert aobj.add(\"d7sms://Token@15551231234/15551231236\")\n\n    body = \"test message\"\n\n    # Send our notification\n    assert aobj.notify(body=body, title=\"title\", notify_type=NotifyType.INFO)\n\n    # Not set to batch, so we send 2 different messages\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.d7networks.com/messages/v1/send\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.d7networks.com/messages/v1/send\"\n    )\n\n    # our first post\n    data = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert len(data[\"messages\"]) == 1\n    message = data[\"messages\"][0]\n    assert len(message[\"recipients\"]) == 1\n    assert message[\"content\"] == \"title\\r\\ntest message\"\n    assert message[\"data_coding\"] == \"auto\"\n\n    # our second post\n    data = loads(mock_post.call_args_list[1][1][\"data\"])\n    assert len(data[\"messages\"]) == 1\n    message = data[\"messages\"][0]\n    assert len(message[\"recipients\"]) == 1\n    assert message[\"content\"] == \"title\\r\\ntest message\"\n    assert message[\"data_coding\"] == \"auto\"\n\n    #\n    # Do a batch test now\n    #\n\n    mock_post.reset_mock()\n\n    # Initializations\n    aobj = Apprise()\n    assert aobj.add(\"d7sms://Token@15551231234/15551231236?batch=yes\")\n\n    body = \"test message\"\n\n    # Send our notification\n    assert aobj.notify(body=body, title=\"title\", notify_type=NotifyType.INFO)\n\n    # All notifications go through in a batch\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.d7networks.com/messages/v1/send\"\n    )\n\n    data = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert len(data[\"messages\"]) == 1\n    message = data[\"messages\"][0]\n    # All of our phone numbers were added here\n    assert len(message[\"recipients\"]) == 2\n    assert \"15551231234\" in message[\"recipients\"]\n    assert \"15551231236\" in message[\"recipients\"]\n    assert message[\"content\"] == \"title\\r\\ntest message\"\n    assert message[\"data_coding\"] == \"auto\"\n"
  },
  {
    "path": "tests/test_plugin_dapnet.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nimport apprise\nfrom apprise import NotifyType\nfrom apprise.plugins.dapnet import DapnetPriority, NotifyDapnet\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"dapnet://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"dapnet://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"dapnet://user:pass\",\n        {\n            # No call-sign specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"dapnet://user@host\",\n        {\n            # No password specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}\".format(\"DF1ABC\"),\n        {\n            # valid call sign\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}/{}\".format(\"DF1ABC\", \"DF1DEF\"),\n        {\n            # valid call signs\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@DF1ABC-1/DF1ABC/DF1ABC-15\",\n        {\n            # valid call signs; but a few are duplicates;\n            # at the end there will only be 1 entry\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n            # Our expected url(privacy=True) startswith() response:\n            # Note that only 1 entry is saved (as other 2 are duplicates)\n            \"privacy_url\": \"dapnet://user:****@D...C?\",\n        },\n    ),\n    (\n        \"dapnet://user:pass@?to={},{}\".format(\"DF1ABC\", \"DF1DEF\"),\n        {\n            # support the to= argument\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}?priority=normal\".format(\"DF1ABC\"),\n        {\n            # valid call sign with priority\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}?priority=em&batch=false\".format(\n            \"/\".join([\"DF1ABC\", \"0A1DEF\"])\n        ),\n        {\n            # valid call sign with priority (emergency) + no batch\n            # transmissions\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}?priority=invalid\".format(\"DF1ABC\"),\n        {\n            # invalid priority\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}?txgroups=dl-all,all\".format(\"DF1ABC\"),\n        {\n            # valid call sign with two transmitter groups\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}?txgroups=invalid\".format(\"DF1ABC\"),\n        {\n            # valid call sign with invalid transmitter group\n            \"instance\": NotifyDapnet,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}/{}\".format(\"abcdefghi\", \"a\"),\n        {\n            # invalid call signs\n            \"instance\": NotifyDapnet,\n            \"notify_response\": False,\n        },\n    ),\n    # Edge cases\n    (\n        \"dapnet://user:pass@{}\".format(\"DF1ABC\"),\n        {\n            \"instance\": NotifyDapnet,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"dapnet://user:pass@{}\".format(\"DF1ABC\"),\n        {\n            \"instance\": NotifyDapnet,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_dapnet_urls():\n    \"\"\"NotifyDapnet() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_dapnet_edge_cases(mock_post):\n    \"\"\"NotifyDapnet() Edge Cases.\"\"\"\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.created\n\n    # test the handling of our batch modes\n    obj = apprise.Apprise.instantiate(\n        \"dapnet://user:pass@{}?batch=yes\".format(\n            \"/\".join([\"DF1ABC\", \"DF1DEF\"])\n        )\n    )\n    assert isinstance(obj, NotifyDapnet)\n\n    # objects will be combined into a single post in batch mode\n    assert len(obj) == 1\n\n    # Force our batch to break into separate messages\n    obj.default_batch_size = 1\n\n    # We'll send 2 messages now\n    assert len(obj) == 2\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n    assert mock_post.call_count == 2\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_dapnet_config_files(mock_post):\n    \"\"\"NotifyDapnet() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - dapnet://user:pass@DF1ABC:\n          - priority: 0\n            tag: dapnet_int normal\n          - priority: \"0\"\n            tag: dapnet_str_int normal\n          - priority: normal\n            tag: dapnet_str normal\n\n          # This will take on normal (default) priority\n          - priority: invalid\n            tag: dapnet_invalid\n\n      - dapnet://user1:pass2@DF1ABC:\n          - priority: 1\n            tag: dapnet_int emerg\n          - priority: \"1\"\n            tag: dapnet_str_int emerg\n          - priority: emergency\n            tag: dapnet_str emerg\n    \"\"\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.created\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 7 servers from that\n    # 4x normal (invalid + 3 exclusivly specified to be so)\n    # 3x emerg\n    assert len(ac.servers()) == 7\n    assert len(aobj) == 7\n    assert len(list(aobj.find(tag=\"normal\"))) == 3\n    for s in aobj.find(tag=\"normal\"):\n        assert s.priority == DapnetPriority.NORMAL\n\n    assert len(list(aobj.find(tag=\"emerg\"))) == 3\n    for s in aobj.find(tag=\"emerg\"):\n        assert s.priority == DapnetPriority.EMERGENCY\n\n    assert len(list(aobj.find(tag=\"dapnet_str\"))) == 2\n    assert len(list(aobj.find(tag=\"dapnet_str_int\"))) == 2\n    assert len(list(aobj.find(tag=\"dapnet_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"dapnet_invalid\"))) == 1\n    assert (\n        next(aobj.find(tag=\"dapnet_invalid\")).priority == DapnetPriority.NORMAL\n    )\n\n    # Notifications work\n    assert aobj.notify(title=\"title\", body=\"body\") is True\n"
  },
  {
    "path": "tests/test_plugin_dbus.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport sys\nimport types\nfrom unittest.mock import ANY, Mock\n\nfrom helpers import reload_plugin\nimport pytest\n\nimport apprise\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n\n@pytest.fixture\ndef mock_dbus_module(mocker):\n    \"\"\"\n    Creates a completely fake 'dbus' module structure in sys.modules\n    so that we can test the plugin even if dbus is not installed.\n    \"\"\"\n    # 1. Create the base 'dbus' module\n    dbus = types.ModuleType(\"dbus\")\n    dbus.DBusException = type(\"DBusException\", (Exception,), {})\n    dbus.Byte = lambda x: x\n    dbus.ByteArray = lambda x: x\n    dbus.Interface = Mock(name=\"Interface\")\n    dbus.SessionBus = Mock(name=\"SessionBus\")\n\n    # 2. Mock the mainloops\n    #    We create the structure: dbus.mainloop.glib.DBusGMainLoop\n    glib_module = types.ModuleType(\"dbus.mainloop.glib\")\n    glib_module.DBusGMainLoop = Mock(name=\"DBusGMainLoop\")\n\n    qt_module = types.ModuleType(\"dbus.mainloop.qt\")\n    qt_module.DBusQtMainLoop = Mock(name=\"DBusQtMainLoop\")\n\n    mainloop_pkg = types.ModuleType(\"dbus.mainloop\")\n    mainloop_pkg.glib = glib_module\n    mainloop_pkg.qt = qt_module\n\n    # Attach mainloop to the dbus module object so tests can access\n    # mock_dbus_module.mainloop\n    dbus.mainloop = mainloop_pkg\n\n    # 3. Create the GI/GdkPixbuf mocks (for image support)\n    gi = types.ModuleType(\"gi\")\n    gi.require_version = Mock()\n    gi.repository = types.SimpleNamespace(\n        GdkPixbuf=types.SimpleNamespace(\n            Pixbuf=Mock(name=\"Pixbuf\")\n        )\n    )\n    # Setup standard Pixbuf behavior\n    mock_image = Mock()\n    mock_image.get_width.return_value = 10\n    mock_image.get_height.return_value = 10\n    mock_image.get_rowstride.return_value = 1\n    mock_image.get_has_alpha.return_value = False\n    mock_image.get_bits_per_sample.return_value = 8\n    mock_image.get_n_channels.return_value = 3\n    mock_image.get_pixels.return_value = b\"pixeldata\"\n\n    gi.repository.GdkPixbuf.Pixbuf.new_from_file.return_value = mock_image\n\n    # 4. Patch everything into sys.modules\n    mocker.patch.dict(sys.modules, {\n        \"dbus\": dbus,\n        \"dbus.mainloop\": mainloop_pkg,\n        \"dbus.mainloop.glib\": glib_module,\n        \"dbus.mainloop.qt\": qt_module,\n        \"gi\": gi,\n        \"gi.repository\": gi.repository\n    })\n\n    return dbus\n\n\ndef test_plugin_dbus_initialization_strategies(mock_dbus_module, mocker):\n    \"\"\"\n    Test the import logic\n    1. GLib present, Qt missing\n    2. Qt present, GLib missing\n    3. Both missing\n    \"\"\"\n    # Scenario A: GLib works, Qt fails\n    # We simulate Qt missing by raising ImportError when accessing it\n    sys.modules[\"dbus.mainloop.qt\"] = None\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import LOOP_GLIB, LOOP_QT, NotifyDBus\n    assert LOOP_GLIB is not None\n    assert LOOP_QT is None\n    assert NotifyDBus.enabled is True\n\n    # Scenario B: Qt works, GLib fails\n    # Reset modules\n    mock_dbus_module.mainloop.qt.DBusQtMainLoop.return_value = \"qt_loop\"\n\n    mocker.patch.dict(sys.modules, {\n        \"dbus.mainloop.glib\": None,\n        \"dbus.mainloop.qt\": mock_dbus_module.mainloop.qt\n    })\n\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import LOOP_GLIB, LOOP_QT, NotifyDBus\n    assert LOOP_GLIB is None\n    assert LOOP_QT is not None\n    assert NotifyDBus.enabled is True\n\n    # Scenario C: Both fail\n    mocker.patch.dict(sys.modules, {\n        \"dbus.mainloop.glib\": None,\n        \"dbus.mainloop.qt\": None\n    })\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NotifyDBus\n    assert NotifyDBus.enabled is False\n\n\ndef test_plugin_dbus_image_support_initialization(mock_dbus_module, mocker):\n    \"\"\"\n    Test the GdkPixbuf import logic\n    \"\"\"\n    # Case 1: Success (Already set up by fixture)\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NOTIFY_DBUS_IMAGE_SUPPORT\n    assert NOTIFY_DBUS_IMAGE_SUPPORT is True\n\n    # Case 2: GI missing\n    mocker.patch.dict(sys.modules, {\"gi\": None})\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NOTIFY_DBUS_IMAGE_SUPPORT\n    assert NOTIFY_DBUS_IMAGE_SUPPORT is False\n\n\ndef test_plugin_dbus_send_success(mock_dbus_module, mocker):\n    \"\"\"\n    Test the happy path for send()\n    \"\"\"\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NotifyDBus\n\n    # Setup the mock chain: SessionBus() ->\n    #   .get_object() -> Interface() -> .Notify()\n    mock_bus = mock_dbus_module.SessionBus.return_value\n    mock_proxy = mock_bus.get_object.return_value\n    mock_interface = mock_dbus_module.Interface.return_value\n\n    obj = NotifyDBus()\n\n    # Send notification\n    assert obj.notify(title=\"Title\", body=\"Body\") is True\n\n    # VERIFICATION\n    # Check SessionBus was called\n    mock_dbus_module.SessionBus.assert_called()\n    # Check Interface was created\n    mock_dbus_module.Interface.assert_called_with(\n        mock_proxy, dbus_interface=\"org.freedesktop.Notifications\")\n    # Check Notify was called\n    assert mock_interface.Notify.called\n    args, _ = mock_interface.Notify.call_args\n    # Arg 3 is Title, Arg 4 is Body\n    assert args[3] == \"Title\"\n    assert args[4] == \"Body\"\n\n\ndef test_plugin_dbus_send_no_title(mock_dbus_module):\n    \"\"\"\n    Test the 'if not title' swap logic\n    \"\"\"\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NotifyDBus\n    mock_interface = mock_dbus_module.Interface.return_value\n\n    obj = NotifyDBus()\n    obj.notify(title=\"\", body=\"OnlyBody\")\n\n    args, _ = mock_interface.Notify.call_args\n    # Title (Arg 3) should now be \"OnlyBody\", Body (Arg 4) empty\n    assert args[3] == \"OnlyBody\"\n    assert args[4] == \"\"\n\n\ndef test_plugin_dbus_send_connection_failure(mock_dbus_module):\n    \"\"\"\n    Test SessionBus raising DBusException\n    \"\"\"\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NotifyDBus\n\n    # Force constructor to crash\n    mock_dbus_module.SessionBus.side_effect = \\\n        mock_dbus_module.DBusException(\"Connection Refused\")\n\n    obj = NotifyDBus()\n    # Should handle exception and return False\n    assert obj.notify(title=\"T\", body=\"B\") is False\n\n\ndef test_plugin_dbus_send_notify_failure(mock_dbus_module):\n    \"\"\"\n    Test Interface.Notify raising Exception\n    \"\"\"\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NotifyDBus\n\n    # Force Notify to crash\n    mock_interface = mock_dbus_module.Interface.return_value\n    mock_interface.Notify.side_effect = Exception(\"Generic Failure\")\n\n    obj = NotifyDBus()\n    assert obj.notify(title=\"T\", body=\"B\") is False\n\n\ndef test_plugin_dbus_image_loading_failure(mock_dbus_module, mocker):\n    \"\"\"\n    Test image loading exception\n    \"\"\"\n    reload_plugin(\"dbus\")\n    # Force GdkPixbuf to crash\n    import gi\n\n    gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = \\\n        Exception(\"Bad Image\")\n\n    # Use Apprise.instantiate to handle parsing cleanly\n    obj = apprise.Apprise.instantiate(\n        \"dbus://?image=yes\", suppress_exceptions=False)\n    spy_logger = mocker.spy(obj, \"logger\")\n\n    # Notification should still succeed (return True), just log a warning\n    assert obj.notify(title=\"T\", body=\"B\") is True\n\n    # Verify the warning was logged\n    spy_logger.warning.assert_called_with(\n        \"Could not load notification icon (%s).\", ANY)\n\n\ndef test_plugin_dbus_url_parsing(mock_dbus_module):\n    \"\"\"\n    Test various URL parameters and instantiation.\n    \"\"\"\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import DBusUrgency, NotifyDBus\n\n    # Test Urgency mapping\n    obj = apprise.Apprise.instantiate(\n        \"dbus://?urgency=high\", suppress_exceptions=False)\n    assert isinstance(obj, NotifyDBus)\n    assert obj.urgency == DBusUrgency.HIGH\n    assert \"urgency=high\" in obj.url()\n\n    obj = apprise.Apprise.instantiate(\n        \"dbus://?priority=low\", suppress_exceptions=False)\n    assert isinstance(obj, NotifyDBus)\n    assert obj.urgency == DBusUrgency.LOW\n    assert \"urgency=low\" in obj.url()\n\n    # Test X/Y coords\n    obj = apprise.Apprise.instantiate(\n        \"dbus://?x=100&y=200\", suppress_exceptions=False)\n    assert obj.x_axis == 100\n    assert obj.y_axis == 200\n    assert \"x=100\" in obj.url()\n    assert \"y=200\" in obj.url()\n\n    # Test Invalid X/Y\n    with pytest.raises(TypeError):\n        NotifyDBus(x_axis=\"invalid\")\n\n\ndef test_plugin_dbus_schema_not_supported(mock_dbus_module, mocker):\n    \"\"\"\n    Dbus test for unsupported schema warning\n    \"\"\"\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NotifyDBus\n\n    with pytest.raises(TypeError):\n        NotifyDBus(schema=\"not-a-real-schema\")\n\n    # Assert warning emitted (message content is stable)\n    with pytest.raises(TypeError):\n        NotifyDBus(schema=\"still-not-real\")\n\n\ndef test_plugin_dbus_send_sets_xy_meta_payload(mock_dbus_module):\n    \"\"\"\n    Covers setting x-y payload setting\n    \"\"\"\n    reload_plugin(\"dbus\")\n    from apprise.plugins.dbus import NotifyDBus\n\n    mock_interface = mock_dbus_module.Interface.return_value\n    obj = NotifyDBus(x_axis=100, y_axis=200)\n\n    assert obj.notify(title=\"T\", body=\"B\") is True\n\n    args, _ = mock_interface.Notify.call_args\n    # meta output (app, id, icon, title, body, actions, meta, timeout)\n    meta = args[6]\n    assert meta[\"x\"] == 100\n    assert meta[\"y\"] == 200\n\n\ndef test_plugin_dbus_send_image_condition_false_skips_pixbuf(\n        mock_dbus_module, mocker):\n    \"\"\"\n    Covers NOTIFY_DBUS_IMAGE_SUPPORT and icon_path flag\n    \"\"\"\n    reload_plugin(\"dbus\")\n    import gi\n\n    from apprise.plugins.dbus import NotifyDBus\n\n    mock_interface = mock_dbus_module.Interface.return_value\n\n    obj = NotifyDBus(include_image=False)\n\n    # Ensure image_path is not even consulted when include_image=False\n    spy_image_path = mocker.spy(obj, \"image_path\")\n\n    assert obj.notify(title=\"T\", body=\"B\") is True\n\n    assert mock_interface.Notify.called\n    assert gi.repository.GdkPixbuf.Pixbuf.new_from_file.called is False\n    assert spy_image_path.called is False\n"
  },
  {
    "path": "tests/test_plugin_dingtalk.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.dingtalk import NotifyDingTalk\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"dingtalk://\",\n        {\n            # No Access Token specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"dingtalk://a_bd_/\",\n        {\n            # invalid Access Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"dingtalk://12345678\",\n        {\n            # access token\n            \"instance\": NotifyDingTalk,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"dingtalk://1...8\",\n        },\n    ),\n    (\n        \"dingtalk://{}/{}\".format(\"a\" * 8, \"1\" * 14),\n        {\n            # access token + phone number\n            \"instance\": NotifyDingTalk,\n        },\n    ),\n    (\n        \"dingtalk://{}/{}/invalid\".format(\"a\" * 8, \"1\" * 3),\n        {\n            # access token + 2 invalid phone numbers\n            \"instance\": NotifyDingTalk,\n        },\n    ),\n    (\n        \"dingtalk://{}/?to={}\".format(\"a\" * 8, \"1\" * 14),\n        {\n            # access token + phone number using 'to'\n            \"instance\": NotifyDingTalk,\n        },\n    ),\n    # Test secret via user@\n    (\n        \"dingtalk://secret@{}/?to={}\".format(\"a\" * 8, \"1\" * 14),\n        {\n            # access token + phone number using 'to'\n            \"instance\": NotifyDingTalk,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"dingtalk://****@a...a\",\n        },\n    ),\n    # Test secret via secret= and token=\n    (\n        \"dingtalk://?token={}&to={}&secret={}\".format(\n            \"b\" * 8, \"1\" * 14, \"a\" * 15\n        ),\n        {\n            # access token + phone number using 'to'\n            \"instance\": NotifyDingTalk,\n            \"privacy_url\": \"dingtalk://****@b...b\",\n        },\n    ),\n    # Invalid secret\n    (\n        \"dingtalk://{}/?to={}&secret=_\".format(\"a\" * 8, \"1\" * 14),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"dingtalk://{}?format=markdown\".format(\"a\" * 8),\n        {\n            # access token\n            \"instance\": NotifyDingTalk,\n        },\n    ),\n    (\n        \"dingtalk://{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyDingTalk,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"dingtalk://{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyDingTalk,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_dingtalk_urls():\n    \"\"\"NotifyDingTalk() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_discord.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timedelta, timezone\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom random import choice\nfrom string import ascii_uppercase as str_alpha, digits as str_num\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType\nfrom apprise.common import OverflowMode\nfrom apprise.plugins.discord import NotifyDiscord\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"discord://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # An invalid url\n    (\n        \"discord://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No webhook_token specified\n    (\n        \"discord://%s\" % (\"i\" * 24),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Provide both an webhook id and a webhook token\n    (\n        \"discord://{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Provide a temporary username\n    (\n        \"discord://l2g@{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # test image= field\n    (\n        \"discord://{}/{}?format=markdown&footer=Yes&image=Yes&ping=Joe\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"discord://{}/{}?format=markdown&footer=Yes&image=No&fields=no\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"discord://jack@{}/{}?format=markdown&footer=Yes&image=Yes\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n            \"privacy_url\": \"discord://jack@i...i/t...t/\",\n        },\n    ),\n    (\n        \"https://discord.com/api/webhooks/{}/{}\".format(\"0\" * 10, \"B\" * 40),\n        {\n            # Native URL Support, support the provided discord URL from their\n            # webpage.\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"https://discordapp.com/api/webhooks/{}/{}\".format(\"0\" * 10, \"B\" * 40),\n        {\n            # Legacy Native URL Support, support the older URL (to be\n            # decomissioned on Nov 7th 2020)\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"https://discordapp.com/api/webhooks/{}/{}?footer=yes\".format(\n            \"0\" * 10, \"B\" * 40\n        ),\n        {\n            # Native URL Support with arguments\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n            \"privacy_url\": \"discord://0...0/B...B/\",\n        },\n    ),\n    (\n        \"https://discordapp.com/api/webhooks/{}/{}?footer=yes&botname=joe\"\n        .format(\"0\" * 10, \"B\" * 40),\n        {\n            # Native URL Support with arguments\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n            \"privacy_url\": \"discord://joe@0...0/B...B/\",\n        },\n    ),\n    (\n        \"discord://{}/{}?format=markdown&avatar=No&footer=No\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"discord://{}/{}?flags=1\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"discord://{}/{}?flags=-1\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            # invalid flags specified (variation 1)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"discord://{}/{}?flags=invalid\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            # invalid flags specified (variation 2)\n            \"instance\": TypeError,\n        },\n    ),\n\n    # different format support\n    (\n        \"discord://{}/{}?format=markdown\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Thread ID\n    (\n        \"discord://{}/{}?format=markdown&thread=abc123\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"discord://{}/{}?format=text\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test with href (title link)\n    (\n        \"discord://{}/{}?hmarkdown=true&ref=http://localhost\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test with url (title link) - Alias of href\n    (\n        \"discord://{}/{}?markdown=true&url=http://localhost\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test with avatar URL\n    (\n        \"discord://{}/{}?avatar_url=http://localhost/test.jpg\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test without image set\n    (\n        \"discord://{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            \"requests_response_code\": requests.codes.no_content,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"discord://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"discord://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"discord://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyDiscord,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_discord_urls():\n    \"\"\"NotifyDiscord() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_notifications(mock_post):\n    \"\"\"NotifyDiscord() Notifications/Ping Support.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Test our header parsing when not lead with a header\n    body = \"\"\"\n    # Heading\n    @everyone and @admin, wake and meet our new user <@123>; <@&456>\"\n    \"\"\"\n\n    results = NotifyDiscord.parse_url(\n        f\"discord://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"webhook_id\"] == webhook_id\n    assert results[\"webhook_token\"] == webhook_token\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == webhook_id\n    assert results[\"fullpath\"] == f\"/{webhook_token}/\"\n    assert results[\"path\"] == f\"/{webhook_token}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"discord\"\n    assert results[\"url\"] == f\"discord://{webhook_id}/{webhook_token}/\"\n\n    instance = NotifyDiscord(**results)\n    assert isinstance(instance, NotifyDiscord)\n\n    response = instance.send(body=body)\n    assert response is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert (\n        details[0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    payload = loads(details[1][\"data\"])\n\n    assert \"allow_mentions\" in payload\n    assert \"users\" in payload[\"allow_mentions\"]\n    assert len(payload[\"allow_mentions\"][\"users\"]) == 1\n    assert \"123\" in payload[\"allow_mentions\"][\"users\"]\n    assert \"roles\" in payload[\"allow_mentions\"]\n    assert len(payload[\"allow_mentions\"][\"roles\"]) == 1\n    assert \"456\" in payload[\"allow_mentions\"][\"roles\"]\n    assert \"parse\" in payload[\"allow_mentions\"]\n    assert len(payload[\"allow_mentions\"][\"parse\"]) == 2\n    assert \"everyone\" in payload[\"allow_mentions\"][\"parse\"]\n    assert \"admin\" in payload[\"allow_mentions\"][\"parse\"]\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    results = NotifyDiscord.parse_url(\n        f\"discord://{webhook_id}/{webhook_token}/?format=text\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"webhook_id\"] == webhook_id\n    assert results[\"webhook_token\"] == webhook_token\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == webhook_id\n    assert results[\"fullpath\"] == f\"/{webhook_token}/\"\n    assert results[\"path\"] == f\"/{webhook_token}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"discord\"\n    assert results[\"url\"] == f\"discord://{webhook_id}/{webhook_token}/\"\n\n    instance = NotifyDiscord(**results)\n    assert isinstance(instance, NotifyDiscord)\n\n    response = instance.send(body=body)\n    assert response is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert (\n        details[0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    payload = loads(details[1][\"data\"])\n\n    # text mode does not ping unless ping is explicitly set to someone\n    assert \"allow_mentions\" not in payload\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Test our header parsing when not lead with a header\n    body = \"\"\" \"\"\"\n\n    results = NotifyDiscord.parse_url(\n        # & -> %26 for role otherwise & separates our URL from further parsing\n        f\"discord://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"webhook_id\"] == webhook_id\n    assert results[\"webhook_token\"] == webhook_token\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == webhook_id\n    assert results[\"fullpath\"] == f\"/{webhook_token}/\"\n    assert results[\"path\"] == f\"/{webhook_token}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"discord\"\n    assert results[\"url\"] == f\"discord://{webhook_id}/{webhook_token}/\"\n    instance = NotifyDiscord(**results)\n    assert isinstance(instance, NotifyDiscord)\n\n    response = instance.send(body=body)\n    assert response is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert (\n        details[0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    payload = loads(details[1][\"data\"])\n\n    assert \"allow_mentions\" in payload\n    assert len(payload[\"allow_mentions\"][\"users\"]) == 1\n    assert \"321\" in payload[\"allow_mentions\"][\"users\"]\n    assert \"<@321>\" in payload[\"content\"]\n    assert len(payload[\"allow_mentions\"][\"roles\"]) == 1\n    assert \"654\" in payload[\"allow_mentions\"][\"roles\"]\n    assert \"<@&654>\" in payload[\"content\"]\n    assert len(payload[\"allow_mentions\"][\"parse\"]) == 1\n    assert \"joe\" in payload[\"allow_mentions\"][\"parse\"]\n    assert \"@joe\" in payload[\"content\"]\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Test our body in text mode, with ping=set\n    body = \"\"\"\n    # Heading\n    @everyone and @admin, wake and meet our new user <@123>; <@&456>\"\n    \"\"\"\n\n    results = NotifyDiscord.parse_url(\n        # & -> %26 for role otherwise & separates our URL from further parsing\n        f\"discord://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>\"\n        \"&format=text\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"webhook_id\"] == webhook_id\n    assert results[\"webhook_token\"] == webhook_token\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == webhook_id\n    assert results[\"fullpath\"] == f\"/{webhook_token}/\"\n    assert results[\"path\"] == f\"/{webhook_token}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"discord\"\n    assert results[\"url\"] == f\"discord://{webhook_id}/{webhook_token}/\"\n\n    instance = NotifyDiscord(**results)\n    assert isinstance(instance, NotifyDiscord)\n\n    response = instance.send(body=body)\n    assert response is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert (\n        details[0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    payload = loads(details[1][\"data\"])\n\n    # Payload only includes elements on ping= line with text mode\n    assert \"allow_mentions\" in payload\n    assert \"users\" in payload[\"allow_mentions\"]\n    assert len(payload[\"allow_mentions\"][\"users\"]) == 1\n    assert \"321\" in payload[\"allow_mentions\"][\"users\"]\n    assert \"roles\" in payload[\"allow_mentions\"]\n    assert len(payload[\"allow_mentions\"][\"roles\"]) == 1\n    assert \"654\" in payload[\"allow_mentions\"][\"roles\"]\n    assert \"parse\" in payload[\"allow_mentions\"]\n    assert len(payload[\"allow_mentions\"][\"parse\"]) == 1\n    assert \"joe\" in payload[\"allow_mentions\"][\"parse\"]\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"time.sleep\")\ndef test_plugin_discord_general(mock_sleep, mock_post):\n    \"\"\"NotifyDiscord() General Checks.\"\"\"\n\n    # Prevent throttling\n    mock_sleep.return_value = True\n\n    # Turn off clock skew for local testing\n    NotifyDiscord.clock_skew = timedelta(seconds=0)\n\n    # Epoch time:\n    epoch = datetime.fromtimestamp(0, timezone.utc)\n\n    # Initialize some generic (but valid) tokens\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = \"\"\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n\n    # Invalid webhook id\n    with pytest.raises(TypeError):\n        NotifyDiscord(webhook_id=None, webhook_token=webhook_token)\n    # Invalid webhook id (whitespace)\n    with pytest.raises(TypeError):\n        NotifyDiscord(webhook_id=\"  \", webhook_token=webhook_token)\n\n    # Invalid webhook token\n    with pytest.raises(TypeError):\n        NotifyDiscord(webhook_id=webhook_id, webhook_token=None)\n    # Invalid webhook token (whitespace)\n    with pytest.raises(TypeError):\n        NotifyDiscord(webhook_id=webhook_id, webhook_token=\"   \")\n\n    obj = NotifyDiscord(\n        webhook_id=webhook_id,\n        webhook_token=webhook_token,\n        footer=True,\n        thumbnail=False,\n    )\n    assert obj.ratelimit_remaining == 1\n\n    # Test that we get a string response\n    assert isinstance(obj.url(), str) is True\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Force a case where there are no more remaining posts allowed\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 0,\n    }\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # behind the scenes, it should cause us to update our rate limit\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n\n    # This should cause us to block\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 10,\n    }\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 10\n\n    # Reset our variable back to 1\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n    # Handle cases where our epoch time is wrong\n    del mock_post.return_value.headers[\"X-RateLimit-Reset\"]\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds() + 1\n        ),\n        \"X-RateLimit-Remaining\": 0,\n    }\n\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Test 429 error response\n    mock_post.return_value.status_code = requests.codes.too_many_requests\n\n    # The below will attempt a second transmission and fail (because we didn't\n    # set up a second post request to pass) :)\n    assert obj.send(body=\"test\") is False\n\n    # Return our object, but place it in the future forcing us to block\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds() - 1\n        ),\n        \"X-RateLimit-Remaining\": 0,\n    }\n    assert obj.send(body=\"test\") is True\n\n    # Return our limits to always work\n    obj.ratelimit_remaining = 1\n\n    # Return our headers to normal\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Simple Markdown Single line of text\n    test_markdown = \"body\"\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    assert isinstance(results, list) is True\n    assert len(results) == 0\n\n    # Test our header parsing when not lead with a header\n    test_markdown = \"\"\"\n    A section of text that has no header at the top.\n    It also has a hash tag # <- in the middle of a\n    string.\n\n    ## Heading 1\n    body\n\n    # Heading 2\n\n    more content\n    on multi-lines\n    \"\"\"\n\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    # we have a description\n    assert isinstance(desc, str) is True\n    assert desc.startswith(\"A section of text that has no header at the top.\")\n    assert desc.endswith(\"string.\")\n\n    assert isinstance(results, list) is True\n    assert len(results) == 2\n    assert results[0][\"name\"] == \"Heading 1\"\n    assert results[0][\"value\"] == \"```md\\nbody\\n```\"\n    assert results[1][\"name\"] == \"Heading 2\"\n    assert (\n        results[1][\"value\"] == \"```md\\nmore content\\n    on multi-lines\\n```\"\n    )\n\n    # Test our header parsing\n    test_markdown = (\n        \"## Heading one\\nbody body\\n\\n\"\n        + \"# Heading 2 ##\\n\\nTest\\n\\n\"\n        + \"more content\\n\"\n        + \"even more content  \\t\\r\\n\\n\\n\"\n        + \"# Heading 3 ##\\n\\n\\n\"\n        + \"normal content\\n\"\n        + \"# heading 4\\n\"\n        + \"#### Heading 5\"\n    )\n\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    assert isinstance(results, list) is True\n    # No desc details filled out\n    assert isinstance(desc, str) is True\n    assert not desc\n\n    # We should have 5 sections (since there are 5 headers identified above)\n    assert len(results) == 5\n    assert results[0][\"name\"] == \"Heading one\"\n    assert results[0][\"value\"] == \"```md\\nbody body\\n```\"\n    assert results[1][\"name\"] == \"Heading 2\"\n    assert (\n        results[1][\"value\"]\n        == \"```md\\nTest\\n\\nmore content\\neven more content\\n```\"\n    )\n    assert results[2][\"name\"] == \"Heading 3\"\n    assert results[2][\"value\"] == \"```md\\nnormal content\\n```\"\n    assert results[3][\"name\"] == \"heading 4\"\n    assert results[3][\"value\"] == \"```\\n```\"\n    assert results[4][\"name\"] == \"Heading 5\"\n    assert results[4][\"value\"] == \"```\\n```\"\n\n    # Create an apprise instance\n    a = Apprise()\n\n    # Our processing is slightly different when we aren't using markdown\n    # as we do not pre-parse content during our notifications\n    assert (\n        a.add(\n            f\"discord://{webhook_id}/{webhook_token}/\"\n            \"?format=markdown&footer=Yes\"\n        )\n        is True\n    )\n\n    # This call includes an image with it's payload:\n    NotifyDiscord.discord_max_fields = 1\n\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.TEXT,\n        )\n        is True\n    )\n\n    # Throw an exception on the forth call to requests.post()\n    # This allows to test our batch field processing\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n    mock_post.side_effect = [\n        response,\n        response,\n        response,\n        requests.RequestException(),\n    ]\n\n    # Test our markdown\n    obj = Apprise.instantiate(\n        f\"discord://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n    assert isinstance(obj, NotifyDiscord)\n    assert (\n        obj.notify(\n            body=test_markdown, title=\"title\", notify_type=NotifyType.INFO\n        )\n        is False\n    )\n    mock_post.side_effect = None\n\n    # Empty String\n    desc, results = obj.extract_markdown_sections(\"\")\n    assert isinstance(results, list) is True\n    assert len(results) == 0\n\n    # No desc details filled out\n    assert isinstance(desc, str) is True\n    assert not desc\n\n    # String without Heading\n    test_markdown = (\n        \"Just a string without any header entries.\\n\" + \"A second line\"\n    )\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    assert isinstance(results, list) is True\n    assert len(results) == 0\n\n    # No desc details filled out\n    assert isinstance(desc, str) is True\n    assert (\n        desc == \"Just a string without any header entries.\\n\" + \"A second line\"\n    )\n\n    # Use our test markdown string during a notification\n    assert (\n        obj.notify(\n            body=test_markdown, title=\"title\", notify_type=NotifyType.INFO\n        )\n        is True\n    )\n\n    # Create an apprise instance\n    a = Apprise()\n\n    # Our processing is slightly different when we aren't using markdown\n    # as we do not pre-parse content during our notifications\n    assert (\n        a.add(\n            f\"discord://{webhook_id}/{webhook_token}/\"\n            \"?format=markdown&footer=Yes\"\n        )\n        is True\n    )\n\n    # This call includes an image with it's payload:\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.TEXT,\n        )\n        is True\n    )\n\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.MARKDOWN,\n        )\n        is True\n    )\n\n    # Toggle our logo availability\n    a.asset.image_url_logo = None\n    assert (\n        a.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Create an apprise instance\n    a = Apprise()\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Test our threading\n    assert (\n        a.add(f\"discord://{webhook_id}/{webhook_token}/?thread=12345\") is True\n    )\n\n    # This call includes an image with it's payload:\n    assert a.notify(body=\"test\", title=\"title\") is True\n\n    assert mock_post.call_count == 1\n    response = mock_post.call_args_list[0][1]\n    assert \"params\" in response\n    assert response[\"params\"].get(\"thread_id\") == \"12345\"\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_overflow(mock_post):\n    \"\"\"NotifyDiscord() Overflow Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Some variables we use to control the data we work with\n    body_len = 8000\n    title_len = 1024\n\n    # Number of characters per line\n    row = 24\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num + \" \") for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    results = NotifyDiscord.parse_url(\n        f\"discord://{webhook_id}/{webhook_token}/?overflow=split\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"webhook_id\"] == webhook_id\n    assert results[\"webhook_token\"] == webhook_token\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == webhook_id\n    assert results[\"fullpath\"] == f\"/{webhook_token}/\"\n    assert results[\"path\"] == f\"/{webhook_token}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"discord\"\n    assert results[\"url\"] == f\"discord://{webhook_id}/{webhook_token}/\"\n\n    instance = NotifyDiscord(**results)\n    assert isinstance(instance, NotifyDiscord)\n\n    results = instance._apply_overflow(\n        body, title=title, overflow=OverflowMode.SPLIT\n    )\n\n    # Ensure we never exceed 2000 characters\n    for entry in results:\n        assert len(entry[\"title\"]) <= instance.title_maxlen\n        assert len(entry[\"title\"]) + len(entry[\"body\"]) <= instance.body_maxlen\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_markdown_extra(mock_post):\n    \"\"\"NotifyDiscord() Markdown Extra Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Reset our apprise object\n    a = Apprise()\n\n    # We want to further test our markdown support to accommodate bug rased on\n    # 2022.10.25; see https://github.com/caronc/apprise/issues/717\n    assert (\n        a.add(\n            f\"discord://{webhook_id}/{webhook_token}/\"\n            \"?format=markdown&footer=Yes\"\n        )\n        is True\n    )\n\n    test_markdown = \"[green-blue](https://google.com)\"\n\n    # This call includes an image with it's payload:\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.TEXT,\n        )\n        is True\n    )\n\n    assert (\n        a.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_attachments(mock_post):\n    \"\"\"NotifyDiscord() Attachment Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    webhook_id = \"C\" * 24\n    webhook_token = \"D\" * 64\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = b\"\"\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.internal_server_error\n    bad_response.content = b\"\"\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n\n    # Test our markdown\n    obj = Apprise.instantiate(\n        f\"discord://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n\n    # attach our content\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Test notifications with mentions and attachments in it\n    assert (\n        obj.notify(\n            body=\"Say hello to <@1234>!\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://discord.com/api/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # update our attachment to be valid\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    mock_post.return_value = None\n    # Throw an exception on the first call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.side_effect = [side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Throw an exception on the second call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.side_effect = [response, side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # handle a bad response\n    bad_response = mock.Mock()\n    bad_response.content = b\"\"\n    bad_response.headers = {}\n    bad_response.status_code = requests.codes.internal_server_error\n    mock_post.side_effect = [response, bad_response]\n\n    # We'll fail now because of an internal exception\n    assert obj.send(body=\"test\", attach=attach) is False\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_markdown_fields_batches_exactly(mock_post):\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = \"\"\n    response.headers = {}\n    mock_post.return_value = response\n\n    # Force tiny batches\n    NotifyDiscord.discord_max_fields = 1\n\n    body = \"# H1\\nv1\\n# H2\\nv2\\n# H3\\nv3\\n\"\n    obj = Apprise.instantiate(\n        f\"discord://{webhook_id}/{webhook_token}/?format=markdown&fields=yes\"\n    )\n    assert isinstance(obj, NotifyDiscord)\n\n    assert obj.send(body=body) is True\n\n    # H1, H2, H3 => 3 fields => 3 posts (since max_fields=1)\n    assert mock_post.call_count == 3\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_markdown_ping_is_additive(mock_post):\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    body = \"Body pings <@111> and <@&222> @everyone\"\n    results = NotifyDiscord.parse_url(\n        f\"discord://{webhook_id}/{webhook_token}/\"\n        \"?format=markdown\"\n        \"&ping=<@333>,<@%26444>,@joe\"\n    )\n    obj = NotifyDiscord(**results)\n\n    assert obj.send(body=body) is True\n    assert mock_post.call_count == 1\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert \"allow_mentions\" in payload\n    # union\n    assert set(payload[\"allow_mentions\"][\"users\"]) == {\"111\", \"333\"}\n    assert set(payload[\"allow_mentions\"][\"roles\"]) == {\"222\", \"444\"}\n    assert set(payload[\"allow_mentions\"][\"parse\"]) == {\"everyone\", \"joe\"}\n    assert payload[\"content\"].startswith(\"👉 \")\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_html_ping_is_exclusive(mock_post):\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    body = \"Body includes <@111> <@&222> @everyone but must be ignored\"\n    results = NotifyDiscord.parse_url(\n        f\"discord://{webhook_id}/{webhook_token}/\"\n        \"?format=html\"\n        \"&ping=<@333>,<@%26444>,@joe\"\n    )\n    obj = NotifyDiscord(**results)\n\n    assert obj.send(body=body) is True\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert set(payload[\"allow_mentions\"][\"users\"]) == {\"333\"}\n    assert set(payload[\"allow_mentions\"][\"roles\"]) == {\"444\"}\n    assert set(payload[\"allow_mentions\"][\"parse\"]) == {\"joe\"}\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_markdown_no_mentions_has_no_allow_mentions(mock_post):\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    results = NotifyDiscord.parse_url(\n        f\"discord://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n    obj = NotifyDiscord(**results)\n\n    assert obj.send(body=\"Hello world\") is True\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert \"allow_mentions\" not in payload\n    assert \"content\" not in payload\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_discord_markdown_single_field_posts_once(mock_post):\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = \"\"\n    response.headers = {}\n    mock_post.return_value = response\n\n    NotifyDiscord.discord_max_fields = 10\n\n    body = \"# H1\\nv1\\n\"\n    obj = Apprise.instantiate(\n        f\"discord://{webhook_id}/{webhook_token}/?format=markdown&fields=yes\"\n    )\n    assert obj.send(body=body) is True\n    assert mock_post.call_count == 1\n"
  },
  {
    "path": "tests/test_plugin_dot.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport json\nimport logging\nfrom unittest import mock\nfrom urllib.parse import parse_qs, urlparse\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.dot import NotifyDot\n\n\nclass DummyAttachment:\n    def __init__(self, payload=\"ZmFjZQ==\"):\n        self._payload = payload\n\n    def base64(self):\n        return self._payload\n\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"dot://\",\n        {\n            # No API key or device ID\n            \"instance\": None,\n        },\n    ),\n    (\n        \"dot://@\",\n        {\n            # No device ID\n            \"instance\": None,\n        },\n    ),\n    (\n        \"dot://apikey@\",\n        {\n            # No device ID\n            \"instance\": None,\n        },\n    ),\n    (\n        \"dot://@device_id\",\n        {\n            # No API key\n            \"instance\": NotifyDot,\n            # Expected notify() response False (because we won't be able\n            # to actually notify anything if no api key was specified\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"dot://apikey@device_id/text/\",\n        {\n            # Everything is okay (text mode)\n            \"instance\": NotifyDot,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"dot://****@device_id/text/\",\n        },\n    ),\n    (\n        \"dot://apikey@device_id/text/?refresh=no\",\n        {\n            # Disable refresh now\n            \"instance\": NotifyDot,\n        },\n    ),\n    (\n        \"dot://apikey@device_id/text/?signature=test_signature\",\n        {\n            # With signature\n            \"instance\": NotifyDot,\n            \"force_debug\": True,\n        },\n    ),\n    (\n        \"dot://apikey@device_id/text/?link=https://example.com\",\n        {\n            # With link\n            \"instance\": NotifyDot,\n        },\n    ),\n    (\n        \"dot://apikey@device_id/image/?link=https://example.com&border=1&dither_type=ORDERED&dither_kernel=ATKINSON\",\n        {\n            # Image mode without payload should fail\n            \"instance\": NotifyDot,\n            \"notify_response\": False,\n            \"attach_response\": True,\n        },\n    ),\n    (\n        \"dot://apikey@device_id/image/?image=ZmFrZUJhc2U2NA==&link=https://example.com&border=1&dither_type=DIFFUSION&dither_kernel=FLOYD_STEINBERG\",\n        {\n            # Image mode with provided image data\n            \"instance\": NotifyDot,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"dot://****@device_id/image/\",\n        },\n    ),\n    (\n        \"dot://apikey@device_id/text/\",\n        {\n            \"instance\": NotifyDot,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"dot://apikey@device_id/text/\",\n        {\n            \"instance\": NotifyDot,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"dot://apikey@device_id/unknown/\",\n        {\n            # Unknown mode defaults back to text\n            \"instance\": NotifyDot,\n            \"privacy_url\": \"dot://****@device_id/text/\",\n        },\n    ),\n)\n\n\ndef test_plugin_dot_urls():\n    \"\"\"NotifyDot() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_notify_dot_image_mode_requires_image():\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n    assert dot.notify(title=\"x\", body=\"y\") is False\n\n\ndef test_notify_dot_image_mode_with_attachment():\n    \"\"\"Test image mode uses first attachment when no image_data provided.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        link=\"https://example.com\",\n        border=1,\n        dither_type=\"ORDERED\",\n        dither_kernel=\"ATKINSON\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            body=\"payload\", title=\"title\", attach=[DummyAttachment(\"YmFzZTY0\")]\n        )\n\n    assert mock_post.called\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"image\"] == \"YmFzZTY0\"\n    assert payload[\"deviceId\"] == \"device\"\n\n\ndef test_notify_dot_image_mode_with_existing_image_data():\n    \"\"\"Test image mode ignores attachment when image_data is provided.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"existing_image_data\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            body=\"test\",\n            title=\"test\",\n            attach=[DummyAttachment(\"attachment_data\")],\n        )\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    # Should use existing image_data, not attachment\n    assert payload[\"image\"] == \"existing_image_data\"\n\n\ndef test_notify_dot_text_mode_with_existing_icon():\n    \"\"\"Test text mode with existing icon (attachment should be ignored).\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        signature=\"footer\",\n        icon=\"aW5jb24=\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            title=\"hello\",\n            body=\"world\",\n            attach=[DummyAttachment(\"attachment_icon\")],\n        )\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert \"image\" not in payload\n    assert payload[\"deviceId\"] == \"device\"\n    assert payload[\"message\"] == \"world\"\n    # Should use existing icon, not attachment\n    assert payload[\"icon\"] == \"aW5jb24=\"\n\n\ndef test_notify_dot_text_mode_uses_attachment_as_icon():\n    \"\"\"Test text mode uses first attachment as icon when no icon provided.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        signature=\"footer\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            title=\"hello\",\n            body=\"world\",\n            attach=[DummyAttachment(\"attachment_icon_data\")],\n        )\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"deviceId\"] == \"device\"\n    assert payload[\"message\"] == \"world\"\n    # Should use attachment as icon\n    assert payload[\"icon\"] == \"attachment_icon_data\"\n\n\ndef test_notify_dot_text_mode_multiple_attachments_warning():\n    \"\"\"Test text mode warns when multiple attachments are provided.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with (\n        mock.patch(\"requests.post\", return_value=response) as mock_post,\n        mock.patch.object(dot.logger, \"warning\") as mock_warning,\n    ):\n        assert dot.send(\n            title=\"hello\",\n            body=\"world\",\n            attach=[\n                DummyAttachment(\"first\"),\n                DummyAttachment(\"second\"),\n            ],\n        )\n        # Should warn about multiple attachments\n        mock_warning.assert_called_once()\n        assert \"Multiple attachments\" in str(mock_warning.call_args)\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    # Should use first attachment only\n    assert payload[\"icon\"] == \"first\"\n\n\ndef test_notify_dot_url_generation():\n    text_dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        signature=\"sig\",\n        icon=\"aW5jb24=\",\n    )\n    text_url = text_dot.url()\n    parsed = urlparse(text_url)\n    assert parsed.path.endswith(\"/text/\")\n    query = parse_qs(parsed.query)\n    assert query[\"refresh\"] == [\"yes\"]\n    assert query[\"signature\"] == [\"sig\"]\n\n    image_dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"aW1hZ2U=\",\n        link=\"https://example.com\",\n        border=1,\n        dither_type=\"ORDERED\",\n        dither_kernel=\"ATKINSON\",\n    )\n    image_url = image_dot.url()\n    parsed_image = urlparse(image_url)\n    assert parsed_image.path.endswith(\"/image/\")\n    image_query = parse_qs(parsed_image.query)\n    assert image_query[\"image\"] == [\"aW1hZ2U=\"]\n    assert image_query[\"border\"] == [\"1\"]\n\n\ndef test_notify_dot_parse_url_mode_and_image():\n    result = NotifyDot.parse_url(\n        \"dot://token@device/image/?image=Zm9vYmFy&link=https://example.com\"\n    )\n    assert result[\"mode\"] == \"image\"\n    assert result[\"image_data\"] == \"Zm9vYmFy\"\n    assert result[\"link\"] == \"https://example.com\"\n\n    fallback = NotifyDot.parse_url(\"dot://token@device/unknown/?refresh=no\")\n    assert fallback[\"mode\"] == \"text\"\n    assert fallback[\"refresh_now\"] is False\n\n\ndef test_notify_dot_invalid_mode():\n    \"\"\"Test invalid mode handling.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"invalid_mode\")\n    assert dot.mode == \"text\"\n\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=123)\n    assert dot.mode == \"text\"\n\n\ndef test_notify_dot_image_data_in_text_mode():\n    \"\"\"Test that image_data is ignored in text mode.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"text\",\n        image_data=\"somebase64\",\n    )\n    assert dot.image_data is None\n\n\ndef test_notify_dot_text_mode_with_title_and_body():\n    \"\"\"Test text mode with title and body.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Test with title and body provided at runtime\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"test_body\", title=\"test_title\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"message\"] == \"test_body\"\n    assert payload[\"title\"] == \"test_title\"\n\n\ndef test_notify_dot_no_device_id():\n    \"\"\"Test behavior when device_id is missing.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=None)\n    assert dot.notify(title=\"test\", body=\"test\") is False\n    assert len(dot) == 0\n\n\ndef test_notify_dot_parse_url_with_all_params():\n    \"\"\"Test parse_url with all parameters.\"\"\"\n    result = NotifyDot.parse_url(\n        \"dot://apikey@device/image/?refresh=yes&signature=sig&icon=icon_b64\"\n        \"&link=https://example.com&border=1&dither_type=ORDERED\"\n        \"&dither_kernel=ATKINSON&image=img_b64\"\n    )\n    assert result[\"mode\"] == \"image\"\n    assert result[\"refresh_now\"] is True\n    assert result[\"signature\"] == \"sig\"\n    assert result[\"icon\"] == \"icon_b64\"\n    assert result[\"link\"] == \"https://example.com\"\n    assert result[\"border\"] == 1\n    assert result[\"dither_type\"] == \"ORDERED\"\n    assert result[\"dither_kernel\"] == \"ATKINSON\"\n    assert result[\"image_data\"] == \"img_b64\"\n\n\ndef test_notify_dot_url_identifier():\n    \"\"\"Test url_identifier property.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n    identifier = dot.url_identifier\n    assert identifier == (\"dot\", \"token\", \"device\", \"image\")\n\n\ndef test_notify_dot_image_mode_with_failed_attachment():\n    \"\"\"Test image mode when attachment fails to convert.\"\"\"\n\n    class FailedAttachment:\n        def base64(self):\n            raise Exception(\"Conversion failed\")\n\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n    # Should fail when no valid image data is available\n    assert dot.notify(\n        title=\"test\", body=\"test\", attach=[FailedAttachment()]\n    ) is False\n\n\ndef test_notify_dot_url_generation_defaults():\n    \"\"\"Test URL generation with default values.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\")\n    url = dot.url()\n    assert \"refresh=yes\" in url\n    assert \"/text/\" in url\n\n    # Test image mode URL with non-default values\n    dot_image = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"img\",\n        dither_type=\"ORDERED\",\n        dither_kernel=\"ATKINSON\",\n    )\n    url_image = dot_image.url()\n    assert \"/image/\" in url_image\n    assert \"dither_type=ORDERED\" in url_image\n    assert \"dither_kernel=ATKINSON\" in url_image\n\n\ndef test_notify_dot_image_mode_with_multiple_attachments():\n    \"\"\"Test image mode with multiple attachments (only first is used).\"\"\"\n\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Multiple attachments provided, only first should be used\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            body=\"test\",\n            title=\"test\",\n            attach=[\n                DummyAttachment(\"first_attachment\"),\n                DummyAttachment(\"second_attachment\"),\n            ],\n        )\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    # Should use first attachment only\n    assert payload[\"image\"] == \"first_attachment\"\n\n\ndef test_notify_dot_text_mode_without_title():\n    \"\"\"Test text mode without title (title is optional).\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Test with empty title - title should not be in payload\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"test message\", title=\"\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    # Title should not be in payload when empty\n    assert \"title\" not in payload\n    assert payload[\"message\"] == \"test message\"\n\n\ndef test_notify_dot_url_generation_with_link():\n    \"\"\"Test URL generation with link in text mode.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        link=\"https://example.com\",\n    )\n    url = dot.url()\n    assert \"link=\" in url\n\n    # Test image mode with border=0 (should not appear in URL for default)\n    dot_image = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"img\",\n        border=0,\n    )\n    url_image = dot_image.url()\n    assert \"border=0\" in url_image\n\n\ndef test_notify_dot_title_handling():\n    \"\"\"Test title handling in text mode.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Test 1: With provided title\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"test\", title=\"provided_title\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"title\"] == \"provided_title\"\n\n    # Test 2: Without provided title, should not include title in payload\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"test\", title=\"\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    # Title should not be in payload when empty\n    assert \"title\" not in payload\n\n\ndef test_notify_dot_image_mode_no_border():\n    \"\"\"Test image mode with border=None to skip border in payload.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"base64img\",\n    )\n    # Manually set border to None\n    dot.border = None\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"test\", title=\"test\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    # Border should not be in payload when None\n    assert \"border\" not in payload\n\n\ndef test_notify_dot_image_mode_no_dither():\n    \"\"\"Test image mode with no dither_type/dither_kernel.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"base64img\",\n    )\n    # Manually set to None to test the conditional branches\n    dot.dither_type = None\n    dot.dither_kernel = None\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"test\", title=\"test\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    # Dither fields should not be in payload when None\n    assert \"ditherType\" not in payload\n    assert \"ditherKernel\" not in payload\n\n\ndef test_notify_dot_text_mode_no_optional_fields():\n    \"\"\"Test text mode with no signature, icon, or link.\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"test body\", title=\"test title\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert \"signature\" not in payload\n    assert \"icon\" not in payload\n    # Link should not be in payload when not set\n    assert payload.get(\"link\") is None or \"link\" not in payload\n\n\ndef test_notify_dot_url_generation_without_defaults():\n    \"\"\"Test URL generation without default dither values.\"\"\"\n    # Test with DIFFUSION (default) - should not appear in URL\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"img\",\n        dither_type=\"DIFFUSION\",\n        dither_kernel=\"FLOYD_STEINBERG\",\n    )\n    url = dot.url()\n    # Default values should not appear in URL\n    assert \"dither_type\" not in url\n    assert \"dither_kernel\" not in url\n\n\ndef test_notify_dot_image_mode_attachment_exception():\n    \"\"\"Test exception handling in image mode when attachment.base64() fails.\"\"\"\n\n    class ExceptionAttachment:\n        def base64(self):\n            raise Exception(\"First attachment fails\")\n\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    # First attachment throws exception, should log warning and fail\n    with mock.patch.object(dot.logger, \"warning\") as mock_warning:\n        assert dot.send(\n            body=\"test\",\n            title=\"test\",\n            attach=[ExceptionAttachment()],\n        ) is False\n        # Should log warning about failed attachment processing\n        assert mock_warning.called\n        # Check that the warning message contains expected text\n        warning_calls = [str(call) for call in mock_warning.call_args_list]\n        assert any(\n            \"Failed to process attachment\" in str(call)\n            for call in warning_calls\n        )\n\n\ndef test_notify_dot_image_mode_attachment_none():\n    \"\"\"Test image mode when attachment is None.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    # Attachment is None, should skip base64() call and fail\n    assert dot.send(\n        body=\"test\",\n        title=\"test\",\n        attach=[None],\n    ) is False\n\n\ndef test_notify_dot_image_mode_attachment_falsy():\n    \"\"\"Test image mode when attachment is falsy.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    # Attachment is falsy (empty string), should skip base64() call and fail\n    class FalsyAttachment:\n        def __bool__(self):\n            return False\n\n        def base64(self):\n            return \"should_not_be_called\"\n\n    assert dot.send(\n        body=\"test\",\n        title=\"test\",\n        attach=[FalsyAttachment()],\n    ) is False\n\n\ndef test_notify_dot_text_mode_attachment_exception():\n    \"\"\"Test exception handling in text mode when attachment.base64() fails.\"\"\"\n\n    class ExceptionAttachment:\n        def base64(self):\n            raise Exception(\"Attachment base64 conversion fails\")\n\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"text\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # First attachment throws exception, should log warning but continue\n    with (\n        mock.patch(\"requests.post\", return_value=response) as mock_post,\n        mock.patch.object(dot.logger, \"warning\") as mock_warning,\n    ):\n        assert dot.send(\n            title=\"hello\",\n            body=\"world\",\n            attach=[ExceptionAttachment()],\n        )\n        # Should log warning about failed attachment processing\n        assert mock_warning.called\n        # Check that the warning message contains expected text\n        warning_calls = [str(call) for call in mock_warning.call_args_list]\n        assert any(\n            \"Failed to process attachment\" in str(call)\n            for call in warning_calls\n        )\n\n    # Should still send notification without icon\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"message\"] == \"world\"\n    assert \"icon\" not in payload\n\n\ndef test_notify_dot_text_mode_attachment_none():\n    \"\"\"Test text mode when attachment is None (covers if attachment branch).\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"text\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Attachment is None, should skip base64() call and continue without icon\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            title=\"hello\",\n            body=\"world\",\n            attach=[None],\n        )\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"message\"] == \"world\"\n    assert \"icon\" not in payload\n\n\ndef test_notify_dot_text_mode_attachment_falsy():\n    \"\"\"Test text mode when attachment is falsy.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"text\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Attachment is falsy, should skip base64() call and continue without icon\n    class FalsyAttachment:\n        def __bool__(self):\n            return False\n\n        def base64(self):\n            return \"should_not_be_called\"\n\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            title=\"hello\",\n            body=\"world\",\n            attach=[FalsyAttachment()],\n        )\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"message\"] == \"world\"\n    assert \"icon\" not in payload\n\n\ndef test_notify_dot_parse_url_no_host():\n    \"\"\"Test parse_url when host is empty (line 578).\"\"\"\n    # Test URL with empty host - device_id should not be added\n    # Using a valid URL structure but testing when host is explicitly empty\n    result = NotifyDot.parse_url(\"dot://apikey@device/text/\")\n    # This should succeed and have a device_id\n    assert result is not None\n    assert result.get(\"device_id\") == \"device\"\n\n\ndef test_notify_dot_url_with_border_not_none():\n    \"\"\"Test URL generation when border is not None (line 515).\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"img\",\n        border=1,\n    )\n    url = dot.url()\n    # Border should be in URL when not None\n    assert \"border=1\" in url\n\n\ndef test_notify_dot_image_mode_with_only_title():\n    \"\"\"Test image mode warning with only title (no body).\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Test with only title, no body - should still warn\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            title=\"test_title\",\n            body=\"\",\n            attach=[DummyAttachment(\"image_data\")],\n        )\n\n    # Should have sent the notification but logged a warning\n    assert mock_post.called\n\n\ndef test_notify_dot_image_mode_with_only_body():\n    \"\"\"Test image mode warning with only body (no title).\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Test with only body, no title - should still warn\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(\n            title=\"\",\n            body=\"test_body\",\n            attach=[DummyAttachment(\"image_data\")],\n        )\n\n    # Should have sent the notification but logged a warning\n    assert mock_post.called\n\n\ndef test_notify_dot_text_mode_without_body():\n    \"\"\"Test text mode with empty body.\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\")\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Test with title but no body\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(body=\"\", title=\"test_title\")\n\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"title\"] == \"test_title\"\n    # Body should not be in payload when empty\n    assert \"message\" not in payload\n\n\ndef test_notify_dot_parse_url_without_host():\n    \"\"\"Test parse_url when URL has no host.\"\"\"\n    # URL with no host (missing device_id) - should return None\n    result = NotifyDot.parse_url(\"dot://apikey@/text/\")\n    # Without a host, the URL is invalid and parse_url returns None\n    assert result is None\n\n\ndef test_notify_dot_image_mode_without_title_and_body():\n    \"\"\"Test image mode without title and body (line 294->300).\"\"\"\n    dot = NotifyDot(\n        apikey=\"token\",\n        device_id=\"device\",\n        mode=\"image\",\n        image_data=\"base64img\",\n    )\n\n    response = mock.Mock()\n    response.status_code = 200\n\n    # Send without title and body - should not trigger warning\n    with mock.patch(\"requests.post\", return_value=response) as mock_post:\n        assert dot.send(title=\"\", body=\"\")\n\n    # Should have sent the notification\n    assert mock_post.called\n    _args, kwargs = mock_post.call_args\n    payload = json.loads(kwargs[\"data\"])\n    assert payload[\"image\"] == \"base64img\"\n    assert \"title\" not in payload\n    assert \"message\" not in payload\n\n\ndef test_notify_dot_parse_url_with_empty_refresh():\n    \"\"\"Test parse_url when refresh query parameter is empty (line 535->539).\"\"\"\n    # Test with no refresh parameter (should default to True)\n    result = NotifyDot.parse_url(\"dot://apikey@device/text/\")\n    assert result is not None\n    # When refresh is not specified, it defaults to True\n    assert result.get(\"refresh_now\") is None  # Not set in parse_url\n\n\ndef test_notify_dot_image_mode_first_attachment_fails():\n    \"\"\"Test image mode when first attachment fails (returns None).\"\"\"\n\n    class FailingAttachment:\n        def base64(self):\n            return None  # Returns None\n\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    # First attachment returns None, should fail immediately\n    assert dot.notify(\n        title=\"\",\n        body=\"\",\n        attach=[FailingAttachment()],\n    ) is False\n\n\ndef test_notify_dot_image_mode_with_empty_attach_list():\n    \"\"\"Test image mode with empty attachments list (line 305->313).\"\"\"\n    dot = NotifyDot(apikey=\"token\", device_id=\"device\", mode=\"image\")\n\n    # Try with empty attachments list\n    # Condition: not image_data and attach -> not None and [] -> False\n    # Should skip the for loop and go directly to line 313\n    assert dot.notify(\n        title=\"\",\n        body=\"\",\n        attach=[],  # Empty list (truthy in Python but loop won't execute)\n    ) is False\n\n\ndef test_notify_dot_parse_url_without_host_field():\n    \"\"\"Test parse_url when host field is None (line 535->539).\"\"\"\n    from apprise import NotifyBase\n\n    # Mock NotifyBase.parse_url to return results with host=None\n    # This triggers the else branch of \"if host:\" at line 535\n    with mock.patch.object(NotifyBase, \"parse_url\") as mock_parse:\n        mock_parse.return_value = {\n            \"user\": \"apikey\",\n            \"password\": None,\n            \"port\": None,\n            \"host\": None,  # host is None - triggers 535->539 branch\n            \"fullpath\": \"/text/\",\n            \"path\": \"\",\n            \"query\": None,\n            \"schema\": \"dot\",\n            \"qsd\": {\"refresh\": \"yes\"},\n            \"secure\": False,\n            \"verify\": True,\n        }\n\n        result = NotifyDot.parse_url(\"dot://fake\")\n\n        # Should have mode but no device_id since host was None\n        assert result is not None\n        assert result.get(\"mode\") == \"text\"\n        assert result.get(\"device_id\") is None\n        assert result.get(\"apikey\") == \"apikey\"\n        assert result.get(\"refresh_now\") is True  # refresh was in qsd\n"
  },
  {
    "path": "tests/test_plugin_email.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime\nfrom email.header import decode_header\nfrom inspect import cleandoc\nimport logging\nimport os\nimport re\nimport shutil\nimport smtplib\nimport sys\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    AppriseAttachment,\n    AttachBase,\n    NotifyBase,\n    NotifyType,\n    PersistentStoreMode,\n    utils,\n)\nfrom apprise.config import ConfigBase\nfrom apprise.exception import AppriseException\nfrom apprise.plugins import email\n\ntry:\n    import pgpy\nexcept ImportError:\n    pgpy = None\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\nTEST_URLS = (\n    ##################################\n    # NotifyEmail\n    ##################################\n    (\n        \"mailto://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"mailtos://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"mailto://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No Username\n    (\n        \"mailtos://:pass@nuxref.com:567\",\n        {\n            # Can't prepare a To address using this expression\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        # invalid Timezone\n        \"mailto://user:pass@fastmail.com?tz=invalid\",\n        {\n            # An error is thrown for this\n            \"instance\": TypeError,\n        },\n    ),\n    # Pre-Configured Email Services\n    (\n        \"mailto://user:pass@gmail.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@hotmail.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@live.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@prontomail.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@yahoo.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@yahoo.ca\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@fastmail.com?tz=UTC\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@sendgrid.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    # GMX Support\n    (\n        \"mailto://user:pass@gmx.net\",\n        {\n            \"instance\": email.NotifyEmail,\n            \"privacy_url\": \"mailtos://user:****@gmx.net\",\n        },\n    ),\n    (\n        \"mailto://user:pass@gmx.com\",\n        {\n            \"instance\": email.NotifyEmail,\n            \"privacy_url\": \"mailtos://user:****@gmx.com\",\n        },\n    ),\n    (\n        \"mailto://user:pass@gmx.de\",\n        {\n            \"instance\": email.NotifyEmail,\n            \"privacy_url\": \"mailtos://user:****@gmx.de\",\n        },\n    ),\n    (\n        # STARTTLS flag checking (and privacy URL expectation)\n        \"mailtos://user:pass@gmx.net?mode=starttls\",\n        {\n            \"instance\": email.NotifyEmail,\n            \"privacy_url\": \"mailtos://user:****@gmx.net\",\n        },\n    ),\n    # Yandex\n    (\n        \"mailto://user:pass@yandex.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@yandex.ru\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@yandex.fr\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    # Custom Emails\n    (\n        \"mailtos://user:pass@nuxref.com:567\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@nuxref.com?mode=ssl\",\n        {\n            # mailto:// with mode=ssl causes us to convert to ssl\n            \"instance\": email.NotifyEmail,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mailtos://user:****@nuxref.com\",\n        },\n    ),\n    (\n        \"mailto://user:pass@nuxref.com:567?format=html\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailtos://user:pass@nuxref.com:567?to=l2g@nuxref.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailtos://user:pass@domain.com?user=admin@mail-domain.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailtos://%20@domain.com?user=admin@mail-domain.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailtos://%20@domain.com?user=admin@mail-domain.com?pgp=yes\",\n        {\n            # Test pgp flag\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailtos://user:pass@nuxref.com:567/l2g@nuxref.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        (\n            \"mailto://user:pass@example.com:2525?user=l2g@example.com\"\n            \"&pass=l2g@apprise!is!Awesome\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        (\n            \"mailto://user:pass@example.com:2525?user=l2g@example.com\"\n            \"&pass=l2g@apprise!is!Awesome&format=text\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        # Test Carbon Copy\n        (\n            \"mailtos://user:pass@example.com?smtp=smtp.example.com\"\n            \"&name=l2g&cc=noreply@example.com,test@example.com\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        # Test Blind Carbon Copy\n        (\n            \"mailtos://user:pass@example.com?smtp=smtp.example.com\"\n            \"&name=l2g&bcc=noreply@example.com,test@example.com\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        # Test Carbon Copy with bad email\n        (\n            \"mailtos://user:pass@example.com?smtp=smtp.example.com\"\n            \"&name=l2g&cc=noreply@example.com,@\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        # Test Blind Carbon Copy with bad email\n        (\n            \"mailtos://user:pass@example.com?smtp=smtp.example.com\"\n            \"&name=l2g&bcc=noreply@example.com,@\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        # Test Reply To\n        (\n            \"mailtos://user:pass@example.com?smtp=smtp.example.com\"\n            \"&name=l2g&reply=test@example.com,test2@example.com\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        # Test Reply To with bad email\n        (\n            \"mailtos://user:pass@example.com?smtp=smtp.example.com\"\n            \"&name=l2g&reply=test@example.com,@\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    # headers\n    (\n        (\n            \"mailto://user:pass@localhost.localdomain\"\n            \"?+X-Customer-Campaign-ID=Apprise\"\n        ),\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    # No Password\n    (\n        \"mailtos://user:@nuxref.com\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    # Invalid From Address; but just gets put as the from name instead\n    # Hence the below generats From: \"@ <user@nuxref.com>\"\n    (\n        \"mailtos://user:pass@nuxref.com?from=@\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    # Invalid From Address\n    (\n        \"mailtos://nuxref.com?user=&pass=.\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid To Address is accepted, but we won't be able to properly email\n    # using the notify() call\n    (\n        \"mailtos://user:pass@nuxref.com?to=@\",\n        {\n            \"instance\": email.NotifyEmail,\n            \"response\": False,\n        },\n    ),\n    # Valid URL, but can't structure a proper email\n    (\n        'mailtos://nuxref.com?user=%20\"&pass=.',\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid From (and To) Address\n    (\n        \"mailtos://nuxref.com?to=test\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid Secure Mode\n    (\n        \"mailtos://user:pass@example.com?mode=notamode\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # STARTTLS flag checking\n    (\n        \"mailtos://user:pass@gmail.com?mode=starttls\",\n        {\n            \"instance\": email.NotifyEmail,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mailtos://user:****@gmail.com\",\n        },\n    ),\n    # SSL flag checking\n    (\n        \"mailtos://user:pass@gmail.com?mode=ssl\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    # Can make a To address using what we have (l2g@nuxref.com)\n    (\n        \"mailtos://nuxref.com?user=l2g&pass=.\",\n        {\n            \"instance\": email.NotifyEmail,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mailtos://l2g:****@nuxref.com\",\n        },\n    ),\n    (\n        \"mailto://user:pass@localhost:2525\",\n        {\n            \"instance\": email.NotifyEmail,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_smtplib_exceptions\": True,\n        },\n    ),\n    # Use of both 'name' and 'from' together; these are synonymous\n    (\n        (\n            \"mailtos://user:pass@nuxref.com?\"\n            \"from=jack@gmail.com&name=Jason<jason@gmail.com>\"\n        ),\n        {\"instance\": email.NotifyEmail},\n    ),\n    # Test no auth at all\n    (\n        \"mailto://localhost?from=test@example.com&to=test@example.com\",\n        {\n            \"instance\": email.NotifyEmail,\n            \"privacy_url\": \"mailto://localhost\",\n        },\n    ),\n    # Test multi-emails where some are bad\n    (\n        \"mailto://user:pass@localhost/test@example.com/test2@/$@!/\",\n        {\n            \"instance\": email.NotifyEmail,\n            \"privacy_url\": \"mailto://user:****@localhost/\",\n        },\n    ),\n    (\n        \"mailto://user:pass@localhost/?bcc=test2@,$@!/\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@localhost/?cc=test2@,$@!/\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n    (\n        \"mailto://user:pass@localhost/?reply=test2@,$@!/\",\n        {\n            \"instance\": email.NotifyEmail,\n        },\n    ),\n)\n\n\n@mock.patch(\"smtplib.SMTP\")\n@mock.patch(\"smtplib.SMTP_SSL\")\ndef test_plugin_email(mock_smtp, mock_smtpssl):\n    \"\"\"NotifyEmail() General Checks.\"\"\"\n\n    # iterate over our dictionary and test it out\n    for url, meta in TEST_URLS:\n\n        # Our expected instance\n        instance = meta.get(\"instance\", None)\n\n        # Our expected server objects\n        self = meta.get(\"self\", None)\n\n        # Our expected Query response (True, False, or exception type)\n        response = meta.get(\"response\", True)\n\n        # Our expected privacy url\n        # Don't set this if don't need to check it's value\n        privacy_url = meta.get(\"privacy_url\")\n\n        test_smtplib_exceptions = meta.get(\"test_smtplib_exceptions\", False)\n\n        # Our mock of our socket action\n        mock_socket = mock.Mock()\n        mock_socket.starttls.return_value = True\n        mock_socket.login.return_value = True\n\n        # Create a mock SMTP Object\n        mock_smtp.return_value = mock_socket\n        mock_smtpssl.return_value = mock_socket\n\n        if test_smtplib_exceptions:\n            # Handle exception testing; first we turn the boolean flag ito\n            # a list of exceptions\n            test_smtplib_exceptions = (\n                smtplib.SMTPHeloError(\n                    0, \"smtplib.SMTPHeloError() not handled\"\n                ),\n                smtplib.SMTPException(\n                    0, \"smtplib.SMTPException() not handled\"\n                ),\n                RuntimeError(0, \"smtplib.HTTPError() not handled\"),\n                smtplib.SMTPRecipientsRefused(\n                    \"smtplib.SMTPRecipientsRefused() not handled\"\n                ),\n                smtplib.SMTPSenderRefused(\n                    0,\n                    \"smtplib.SMTPSenderRefused() not handled\",\n                    \"addr@example.com\",\n                ),\n                smtplib.SMTPDataError(\n                    0, \"smtplib.SMTPDataError() not handled\"\n                ),\n                smtplib.SMTPServerDisconnected(\n                    \"smtplib.SMTPServerDisconnected() not handled\"\n                ),\n            )\n\n        try:\n            obj = Apprise.instantiate(url, suppress_exceptions=False)\n\n            if obj is None:\n                # We're done (assuming this is what we were expecting)\n                assert instance is None\n                continue\n\n            if instance is None:\n                # Expected None but didn't get it\n                raise AssertionError()\n\n            assert isinstance(obj, instance)\n\n            if isinstance(obj, NotifyBase):\n                # We loaded okay; now lets make sure we can reverse this url\n                assert isinstance(obj.url(), str)\n\n                # Get our URL Identifier\n                assert isinstance(obj.url_id(), str)\n\n                # Verify we can acquire a target count as an integer\n                assert isinstance(len(obj), int)\n\n                # Test url() with privacy=True\n                assert isinstance(obj.url(privacy=True), str)\n\n                # Some Simple Invalid Instance Testing\n                assert instance.parse_url(None) is None\n                assert instance.parse_url(object) is None\n                assert instance.parse_url(42) is None\n\n                if privacy_url and not obj.url(privacy=True).startswith(\n                    # Assess that our privacy url is as expected\n                    privacy_url\n                ):\n                    raise AssertionError(\n                        f\"URL: {url} Privacy URL:\"\n                        f\" '{obj.url(privacy=True)[:len(privacy_url)]}' !=\"\n                        f\" expected '{privacy_url}'\"\n                    )\n\n                # Instantiate the exact same object again using the URL from\n                # the one that was already created properly\n                obj_cmp = Apprise.instantiate(obj.url())\n\n                # Our object should be the same instance as what we had\n                # originally expected above.\n                if not isinstance(obj_cmp, NotifyBase):\n                    # Assert messages are hard to trace back with the way\n                    # these tests work. Just printing before throwing our\n                    # assertion failure makes things easier to debug later on\n                    raise AssertionError()\n\n                # Verify there is no change from the old and the new\n                assert len(obj) == len(obj_cmp), (\n                    f\"{len(obj)} targets found in \"\n                    f\"{obj.url(privacy=True)}, \"\n                    f\"But {len(obj_cmp)} targets found in \"\n                    f\"{obj_cmp.url(privacy=True)}\"\n                )\n            if self:\n                # Iterate over our expected entries inside of our object\n                for key, val in self.items():\n                    # Test that our object has the desired key\n                    assert hasattr(key, obj)\n                    assert getattr(key, obj) == val\n\n            try:\n                if test_smtplib_exceptions is False:\n                    # Verify we can acquire a target count as an integer\n                    targets = len(obj)\n\n                    # check that we're as expected\n                    assert (\n                        obj.notify(\n                            title=\"test\",\n                            body=\"body\",\n                            notify_type=NotifyType.INFO,\n                        )\n                        == response\n                    )\n\n                    if response:\n                        # If we successfully got a response, there must have\n                        # been at least 1 target present\n                        assert targets > 0\n\n                else:\n                    for exception in test_smtplib_exceptions:\n                        mock_socket.sendmail.side_effect = exception\n                        try:\n                            assert (\n                                obj.notify(\n                                    title=\"test\",\n                                    body=\"body\",\n                                    notify_type=NotifyType.INFO,\n                                )\n                                is False\n                            )\n\n                        except AssertionError:\n                            # Don't mess with these entries\n                            raise\n\n                        except Exception:\n                            # We can't handle this exception type\n                            raise\n\n            except AssertionError:\n                # Don't mess with these entries\n                raise\n\n            except Exception as e:\n                # Check that we were expecting this exception to happen\n                if not isinstance(e, response):\n                    raise\n\n        except AssertionError:\n            # Don't mess with these entries\n            raise\n\n        except Exception as e:\n            # Handle our exception\n            if instance is None:\n                raise\n\n            if not isinstance(e, instance):\n                raise\n\n\n@mock.patch(\"smtplib.SMTP\")\n@mock.patch(\"smtplib.SMTP_SSL\")\ndef test_plugin_email_webbase_lookup(mock_smtp, mock_smtpssl):\n    \"\"\"NotifyEmail() Web Based Lookup Tests.\"\"\"\n\n    # Insert a test email at the head of our table\n    email.templates.EMAIL_TEMPLATES = (\n        *(\n            (\n                \"Testing Lookup\",\n                re.compile(r\"^(?P<id>[^@]+)@(?P<domain>l2g\\.com)$\", re.I),\n                {\n                    \"port\": 123,\n                    \"smtp_host\": \"smtp.l2g.com\",\n                    \"secure\": True,\n                    \"login_type\": (email.WebBaseLogin.USERID,),\n                },\n            ),\n        ),\n        *email.templates.EMAIL_TEMPLATES,\n    )\n\n    obj = Apprise.instantiate(\n        \"mailto://user:pass@l2g.com\",\n        suppress_exceptions=True,\n    )\n\n    assert isinstance(obj, email.NotifyEmail)\n    assert len(obj.targets) == 1\n    assert (False, \"user@l2g.com\") in obj.targets\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"user@l2g.com\"\n    assert obj.password == \"pass\"\n    assert obj.user == \"user\"\n    assert obj.secure is True\n    assert obj.port == 123\n    assert obj.smtp_host == \"smtp.l2g.com\"\n\n    # We get the same results if an email is identified as the username\n    # because the USERID variable forces that we can't use an email\n    obj = Apprise.instantiate(\n        \"mailto://_:pass@l2g.com?user=user@test.com\", suppress_exceptions=True\n    )\n    assert obj.user == \"user\"\n\n\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_smtplib_init_fail(mock_smtplib):\n    \"\"\"NotifyEmail() Test exception handling when calling smtplib.SMTP()\"\"\"\n\n    obj = Apprise.instantiate(\n        \"mailto://user:pass@gmail.com\", suppress_exceptions=False\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    # Support Exception handling of smtplib.SMTP\n    mock_smtplib.side_effect = RuntimeError(\"Test\")\n\n    assert (\n        obj.notify(body=\"body\", title=\"test\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n    # A handled and expected exception\n    mock_smtplib.side_effect = smtplib.SMTPException(\"Test\")\n    assert (\n        obj.notify(body=\"body\", title=\"test\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_smtplib_send_okay(mock_smtplib):\n    \"\"\"NotifyEmail() Test a successfully sent email.\"\"\"\n\n    # Defaults to HTML\n    obj = Apprise.instantiate(\n        \"mailto://user:pass@gmail.com\", suppress_exceptions=False\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    # Support an email simulation where we can correctly quit\n    mock_smtplib.starttls.return_value = True\n    mock_smtplib.login.return_value = True\n    mock_smtplib.sendmail.return_value = True\n    mock_smtplib.quit.return_value = True\n\n    assert (\n        obj.notify(body=\"body\", title=\"test\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Set Text\n    obj = Apprise.instantiate(\n        \"mailto://user:pass@gmail.com?format=text\", suppress_exceptions=False\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert (\n        obj.notify(body=\"body\", title=\"test\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Create an apprise object to work with as well\n    a = Apprise()\n    assert a.add(\"mailto://user:pass@gmail.com?format=text\")\n\n    # Send Attachment with success\n    attach = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # same results happen from our Apprise object\n    assert a.notify(body=\"body\", title=\"test\", attach=attach) is True\n\n    # test using an Apprise Attachment object\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=AppriseAttachment(attach),\n        )\n        is True\n    )\n\n    # same results happen from our Apprise object\n    assert (\n        a.notify(body=\"body\", title=\"test\", attach=AppriseAttachment(attach))\n        is True\n    )\n\n    max_file_size = AttachBase.max_file_size\n    # Now do a case where the file can't be sent\n\n    AttachBase.max_file_size = 1\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"test\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # same results happen from our Apprise object\n    assert a.notify(body=\"body\", title=\"test\", attach=attach) is False\n\n    # Restore value\n    AttachBase.max_file_size = max_file_size\n\n\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_smtplib_send_multiple_recipients(mock_smtplib):\n    \"\"\"Verify that NotifyEmail() will use a single SMTP session for submitting\n    multiple emails.\"\"\"\n\n    # Defaults to HTML\n    obj = Apprise.instantiate(\n        \"mailto://user:pass@mail.example.org?\"\n        \"to=foo@example.net,bar@example.com&\"\n        \"cc=baz@example.org&bcc=qux@example.org\",\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert (\n        obj.notify(body=\"body\", title=\"test\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert mock_smtplib.mock_calls == [\n        mock.call(\"mail.example.org\", 25, None, timeout=15),\n        mock.call().login(\"user\", \"pass\"),\n        mock.call().sendmail(\n            \"user@mail.example.org\",\n            [\"foo@example.net\", \"baz@example.org\", \"qux@example.org\"],\n            mock.ANY,\n        ),\n        mock.call().sendmail(\n            \"user@mail.example.org\",\n            [\"bar@example.com\", \"baz@example.org\", \"qux@example.org\"],\n            mock.ANY,\n        ),\n        mock.call().quit(),\n    ]\n\n    # No from= used in the above\n    assert re.match(r\".*from=.*\", obj.url()) is None\n    # No mode= as this isn't a secure connection\n    assert re.match(r\".*mode=.*\", obj.url()) is None\n    # No smtp= as the SMTP server is the same as the hostname in this case\n    assert re.match(r\".*smtp=.*\", obj.url()) is None\n    # URL is assembled based on provided user\n    assert (\n        re.match(r\"^mailto://user:pass\\@mail.example.org/.*\", obj.url())\n        is not None\n    )\n\n    # Verify our added emails are still part of the URL\n    assert re.match(r\".*/foo%40example.net[/?].*\", obj.url()) is not None\n    assert re.match(r\".*/bar%40example.com[/?].*\", obj.url()) is not None\n\n    assert re.match(r\".*bcc=qux%40example.org.*\", obj.url()) is not None\n    assert re.match(r\".*cc=baz%40example.org.*\", obj.url()) is not None\n\n\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_timezone(mock_smtp):\n    \"\"\"NotifyEmail() Timezone Handling\"\"\"\n\n    response = mock.Mock()\n    mock_smtp.return_value = response\n\n    # Loads America/Toronto\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@hotmail.com:123\"\n        \"?tz=Toronto\"\n    )\n    assert isinstance(results, dict)\n    # timezone is detected\n    assert \"tz\" in results\n\n    # Instantiate the object\n    obj = email.NotifyEmail(**results)\n    assert isinstance(obj, email.NotifyEmail)\n    assert obj.tzinfo.key == \"America/Toronto\"\n    # Verify our URL has defined our timezone\n    # %2F = escaped '/'\n    assert \"tz=America%2FToronto\" in obj.url()\n\n    # No Timezone setup/default\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@hotmail.com:1235\"\n    )\n    assert \"tz\" not in results\n\n    # Instantiate the object\n    obj = email.NotifyEmail(**results)\n    assert isinstance(obj, email.NotifyEmail)\n    # Defaults to our system\n    assert obj.tzinfo == datetime.now().astimezone().tzinfo\n    assert \"tz=\" not in obj.url()\n\n    # Now we'll work with an Asset to identify how it can hold\n    # our default global variable (initialization proves case\n    # insensitive initialization is supported)\n    asset = AppriseAsset(timezone=\"aMErica/vanCOUver\")\n\n    # Instatiate our object once again using the same variable set\n    # as above\n    obj = email.NotifyEmail(**results, asset=asset)\n    # Defaults to our system\n    # lower() is required since Mac and Window are not case sensitive and will\n    # See output as it was passed in and not corrected per IANA\n    assert obj.tzinfo.key.lower() == \"america/vancouver\"\n    assert \"tz=\" not in obj.url()\n\n    # Having ourselves a default variable also does not prevent\n    # anyone from defining their own over-ride is still supported:\n\n    # Loads America/Montreal\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@hotmail.com:321\"\n        \"?tz=Montreal\"\n    )\n    assert isinstance(results, dict)\n    # timezone is detected\n    assert \"tz\" in results\n\n    # Instantiate the object\n    obj = email.NotifyEmail(**results)\n    assert isinstance(obj, email.NotifyEmail)\n    assert obj.tzinfo.key == \"America/Montreal\"\n    # Verify our URL has defined our timezone\n    # %2F = escaped '/'\n    assert \"tz=America%2FMontreal\" in obj.url()\n\n\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_smtplib_internationalization(mock_smtp):\n    \"\"\"NotifyEmail() Internationalization Handling.\"\"\"\n\n    # i18n test\n    email_url = \"\".join([\n        \"mailto://user:pass@gmail.com?\",\n        \"name=Например%20так\",  # noqa: RUF001\n    ])\n\n    obj = Apprise.instantiate(\n        email_url,\n        suppress_exceptions=False,\n    )\n\n    assert isinstance(obj, email.NotifyEmail)\n\n    class SMTPMock:\n        def sendmail(self, *args, **kwargs):\n            \"\"\"Over-ride sendmail calls so we can check our our\n            internationalization formatting went.\"\"\"\n\n            match_subject = re.search(\n                r\"\\n?(?P<line>Subject: (?P<subject>(.+?)))\\n(?:[a-z0-9-]+:)\",\n                args[2],\n                re.I | re.M | re.S,\n            )\n            assert match_subject is not None\n\n            match_from = re.search(\n                r\"^(?P<line>From: (?P<name>.+) <(?P<email>[^>]+)>)$\",\n                args[2],\n                re.I | re.M,\n            )\n            assert match_from is not None\n\n            # Verify our output was correctly stored\n            assert match_from.group(\"email\") == \"user@gmail.com\"\n\n            assert (\n                decode_header(match_from.group(\"name\"))[0][0].decode(\"utf-8\")\n                == \"Например так\"\n            )\n\n            assert (\n                decode_header(match_subject.group(\"subject\"))[0][0].decode(\n                    \"utf-8\"\n                )\n                == \"دعونا نجعل العالم مكانا أفضل.\"\n            )\n\n        # Dummy Function\n        def quit(self, *args, **kwargs):\n            return True\n\n        # Dummy Function\n        def starttls(self, *args, **kwargs):\n            return True\n\n        # Dummy Function\n        def login(self, *args, **kwargs):\n            return True\n\n    # Prepare our object we will test our generated email against\n    mock_smtp.return_value = SMTPMock()\n\n    # Further test encoding through the message content as well\n    assert (\n        obj.notify(\n            # Google Translated to Arabic:\n            #  \"Let's make the world a better place.\"\n            title=\"دعونا نجعل العالم مكانا أفضل.\",\n            # Google Translated to Hungarian: \"One line of code at a time.'\n            body=\"Egy sor kódot egyszerre.\",\n            notify_type=NotifyType.INFO,\n        )\n        is True\n    )\n\n\ndef test_plugin_email_url_escaping():\n    \"\"\"NotifyEmail() Test that user/passwords are properly escaped from URL.\"\"\"\n    # quote(' %20')\n    passwd = \"%20%2520\"\n\n    # Basically we want to check that ' ' equates to %20 and % equates to %25\n    # So the above translates to ' %20' (a space in front of %20).  We want\n    # to verify the handling of the password escaping and when it happens.\n    # a very bad response would be '  ' (double space)\n    obj = email.NotifyEmail.parse_url(\n        f\"mailto://user:{passwd}@gmail.com?format=text\"\n    )\n\n    assert isinstance(obj, dict)\n    assert \"password\" in obj\n\n    # Escaping doesn't happen at this stage because we want to leave this to\n    # the plugins discretion\n    assert obj.get(\"password\") == \"%20%2520\"\n\n    obj = Apprise.instantiate(\n        f\"mailto://user:{passwd}@gmail.com?format=text\",\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    # The password is escaped only 'once'\n    assert obj.password == \" %20\"\n\n\ndef test_plugin_email_url_variations():\n    \"\"\"NotifyEmail() Test URL variations to ensure parsing is correct.\"\"\"\n    # Test variations of username required to be an email address\n    # user@example.com\n    obj = Apprise.instantiate(\n        \"mailto://{user}:{passwd}@example.com?smtp=example.com\".format(\n            user=\"apprise%40example21.ca\", passwd=\"abcd123\"\n        ),\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert obj.password == \"abcd123\"\n    assert obj.user == \"apprise@example21.ca\"\n\n    # No from= used in the above\n    assert re.match(r\".*from=.*\", obj.url()) is None\n    # No mode= as this isn't a secure connection\n    assert re.match(r\".*mode=.*\", obj.url()) is None\n    # No smtp= as the SMTP server is the same as the hostname in this case\n    # even though it was explicitly specified\n    assert re.match(r\".*smtp=.*\", obj.url()) is None\n    # URL is assembled based on provided user\n    assert (\n        re.match(r\"^mailto://apprise:abcd123\\@example.com/.*\", obj.url())\n        is not None\n    )\n\n    # test username specified in the url body (as an argument)\n    # this always over-rides the entry at the front of the url\n    obj = Apprise.instantiate(\n        \"mailto://_:{passwd}@example.com?user={user}\".format(\n            user=\"apprise%40example21.ca\", passwd=\"abcd123\"\n        ),\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert obj.password == \"abcd123\"\n    assert obj.user == \"apprise@example21.ca\"\n\n    # No from= used in the above\n    assert re.match(r\".*from=.*\", obj.url()) is None\n    # No mode= as this isn't a secure connection\n    assert re.match(r\".*mode=.*\", obj.url()) is None\n    # No smtp= as the SMTP server is the same as the hostname in this case\n    assert re.match(r\".*smtp=.*\", obj.url()) is None\n    # URL is assembled based on provided user\n    assert (\n        re.match(r\"^mailto://apprise:abcd123\\@example.com/.*\", obj.url())\n        is not None\n    )\n\n    # test user and password specified in the url body (as an argument)\n    # this always over-rides the entries at the front of the url\n    obj = Apprise.instantiate(\n        \"mailtos://_:_@example.com?user={user}&pass={passwd}\".format(\n            user=\"apprise%40example21.ca\", passwd=\"abcd123\"\n        ),\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert obj.password == \"abcd123\"\n    assert obj.user == \"apprise@example21.ca\"\n    assert len(obj.targets) == 1\n    assert (False, \"apprise@example.com\") in obj.targets\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"apprise@example.com\"\n    assert obj.targets[0][0] is False\n    assert obj.targets[0][1] == obj.from_addr[1]\n\n    # No from= used in the above\n    assert re.match(r\".*from=.*\", obj.url()) is None\n    # Default mode is starttls\n    assert re.match(r\".*mode=starttls.*\", obj.url()) is not None\n    # No smtp= as the SMTP server is the same as the hostname in this case\n    assert re.match(r\".*smtp=.*\", obj.url()) is None\n    # URL is assembled based on provided user\n    assert (\n        re.match(r\"^mailtos://apprise:abcd123\\@example.com/.*\", obj.url())\n        is not None\n    )\n\n    # test user and password specified in the url body (as an argument)\n    # this always over-rides the entries at the front of the url\n    # this is similar to the previous test except we're only specifying\n    # this information in the kwargs\n    obj = Apprise.instantiate(\n        \"mailto://example.com?user={user}&pass={passwd}\".format(\n            user=\"apprise%40example21.ca\", passwd=\"abcd123\"\n        ),\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert obj.password == \"abcd123\"\n    assert obj.user == \"apprise@example21.ca\"\n    assert len(obj.targets) == 1\n    assert (False, \"apprise@example.com\") in obj.targets\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"apprise@example.com\"\n    assert obj.targets[0][0] is False\n    assert obj.targets[0][1] == obj.from_addr[1]\n    assert obj.smtp_host == \"example.com\"\n\n    # No from= used in the above\n    assert re.match(r\".*from=.*\", obj.url()) is None\n    # No mode= as this isn't a secure connection\n    assert re.match(r\".*mode=.*\", obj.url()) is None\n    # No smtp= as the SMTP server is the same as the hostname in this case\n    assert re.match(r\".*smtp=.*\", obj.url()) is None\n    # URL is assembled based on provided user\n    assert (\n        re.match(r\"^mailto://apprise:abcd123\\@example.com/.*\", obj.url())\n        is not None\n    )\n\n    # test a complicated example\n    obj = Apprise.instantiate(\n        \"mailtos://{user}:{passwd}@{host}:{port}\"\n        \"?smtp={smtp_host}&format=text&from=Charles<{this}>&to={that}\".format(\n            user=\"apprise%40example21.ca\",\n            passwd=\"abcd123\",\n            host=\"example.com\",\n            port=1234,\n            this=\"from@example.jp\",\n            that=\"to@example.jp\",\n            smtp_host=\"smtp.example.edu\",\n        ),\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert obj.password == \"abcd123\"\n    assert obj.user == \"apprise@example21.ca\"\n    assert obj.host == \"example.com\"\n    assert obj.port == 1234\n    assert obj.smtp_host == \"smtp.example.edu\"\n    assert len(obj.targets) == 1\n    assert (False, \"to@example.jp\") in obj.targets\n    assert obj.from_addr[0] == \"Charles\"\n    assert obj.from_addr[1] == \"from@example.jp\"\n    assert (\n        re.match(r\".*from=Charles\\+%3Cfrom%40example.jp%3E.*\", obj.url())\n        is not None\n    )\n\n    # Test Tagging under various urll encodings\n    for toaddr in (\n        \"/john.smith+mytag@domain.com\",\n        \"?to=john.smith+mytag@domain.com\",\n        \"/john.smith%2Bmytag@domain.com\",\n        \"?to=john.smith%2Bmytag@domain.com\",\n    ):\n\n        obj = Apprise.instantiate(f\"mailto://user:pass@domain.com{toaddr}\")\n        assert isinstance(obj, email.NotifyEmail)\n        assert obj.password == \"pass\"\n        assert obj.user == \"user\"\n        assert obj.host == \"domain.com\"\n        assert obj.from_addr[0] == obj.app_id\n        assert obj.from_addr[1] == \"user@domain.com\"\n        assert len(obj.targets) == 1\n        assert obj.targets[0][0] is False\n        assert obj.targets[0][1] == \"john.smith+mytag@domain.com\"\n\n\ndef test_plugin_email_dict_variations():\n    \"\"\"NotifyEmail() Test email dictionary variations to ensure parsing is\n    correct.\"\"\"\n    # Test variations of username required to be an email address\n    # user@example.com\n    obj = Apprise.instantiate(\n        {\n            \"schema\": \"mailto\",\n            \"user\": \"apprise@example.com\",\n            \"password\": \"abd123\",\n            \"host\": \"example.com\",\n        },\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, email.NotifyEmail)\n\n\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_url_parsing(mock_smtp, mock_smtp_ssl):\n    \"\"\"NotifyEmail() Test email url parsing.\"\"\"\n\n    response = mock.Mock()\n    mock_smtp_ssl.return_value = response\n    mock_smtp.return_value = response\n\n    # Test variations of username required to be an email address\n    # user@example.com; we also test an over-ride port on a template driven\n    # mailto:// entry\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@hotmail.com:444\"\n        \"?to=user2@yahoo.com&name=test%20name\"\n    )\n    assert isinstance(results, dict)\n    assert results[\"from_addr\"] == \"test name\"\n    assert results[\"user\"] == \"user\"\n    assert results[\"port\"] == 444\n    assert results[\"host\"] == \"hotmail.com\"\n    assert results[\"password\"] == \"pass123\"\n    assert \"user2@yahoo.com\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"user@hotmail.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"user2@yahoo.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    # Our URL port was over-ridden (on template) to use 444\n    # We can verify that this was correctly saved\n    assert obj.url().startswith(\n        \"mailtos://user:pass123@hotmail.com:444/user2%40yahoo.com\"\n    )\n    assert \"mode=starttls\" in obj.url()\n    assert \"smtp=smtp-mail.outlook.com\" in obj.url()\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    # The below switches the `name` with the `to` to verify the results\n    # are the same; it also verfies that the mode gets changed to SSL\n    # instead of STARTTLS\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@hotmail.com?smtp=override.com\"\n        \"&name=test%20name&to=user2@yahoo.com&mode=ssl\"\n    )\n    assert isinstance(results, dict)\n    assert results[\"from_addr\"] == \"test name\"\n    assert results[\"user\"] == \"user\"\n    assert results[\"host\"] == \"hotmail.com\"\n    assert results[\"password\"] == \"pass123\"\n    assert \"user2@yahoo.com\" in results[\"targets\"]\n    assert results[\"secure_mode\"] == \"ssl\"\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 1\n    assert response.starttls.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"user@hotmail.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"user2@yahoo.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    user, pw = response.login.call_args[0]\n    # the SMTP Server was ovr\n    assert pw == \"pass123\"\n    assert user == \"user\"\n\n    assert obj.url().startswith(\n        \"mailtos://user:pass123@hotmail.com/user2%40yahoo.com\"\n    )\n    # Test that our template over-ride worked\n    assert \"mode=ssl\" in obj.url()\n    assert \"smtp=override.com\" in obj.url()\n    # No reply address specified\n    assert \"reply=\" not in obj.url()\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    #\n    # Test outlook/hotmail lookups\n    #\n    results = email.NotifyEmail.parse_url(\"mailtos://user:pass123@hotmail.com\")\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    assert obj.smtp_host == \"smtp-mail.outlook.com\"\n    # No entries in the reply_to\n    assert not obj.reply_to\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass123\"\n    assert user == \"user@hotmail.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\"mailtos://user:pass123@outlook.com\")\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    assert obj.smtp_host == \"smtp.outlook.com\"\n    # No entries in the reply_to\n    assert not obj.reply_to\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass123\"\n    assert user == \"user@outlook.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@outlook.com.au\"\n    )\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    assert obj.smtp_host == \"smtp.outlook.com\"\n    # No entries in the reply_to\n    assert not obj.reply_to\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass123\"\n    assert user == \"user@outlook.com.au\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    # Consisitency Checks\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://outlook.com?smtp=smtp.outlook.com\"\n        \"&user=user@outlook.com&pass=app.pw\"\n    )\n    obj1 = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj1, email.NotifyEmail)\n    assert obj1.smtp_host == \"smtp.outlook.com\"\n    assert obj1.user == \"user@outlook.com\"\n    assert obj1.password == \"app.pw\"\n    assert obj1.secure_mode == \"starttls\"\n    assert obj1.port == 587\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj1.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"app.pw\"\n    assert user == \"user@outlook.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\"mailtos://user:app.pw@outlook.com\")\n    obj2 = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj2, email.NotifyEmail)\n    assert obj2.smtp_host == obj1.smtp_host\n    assert obj2.user == obj1.user\n    assert obj2.password == obj1.password\n    assert obj2.secure_mode == obj1.secure_mode\n    assert obj2.port == obj1.port\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj2.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"app.pw\"\n    assert user == \"user@outlook.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\"mailto://user:pass@comcast.net\")\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    assert obj.smtp_host == \"smtp.comcast.net\"\n    assert obj.user == \"user@comcast.net\"\n    assert obj.password == \"pass\"\n    assert obj.secure_mode == \"ssl\"\n    assert obj.port == 465\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 1\n    assert response.starttls.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass\"\n    assert user == \"user@comcast.net\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\"mailtos://user:pass123@live.com\")\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    # No entries in the reply_to\n    assert not obj.reply_to\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass123\"\n    assert user == \"user@live.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\"mailtos://user:pass123@hotmail.com\")\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    # No entries in the reply_to\n    assert not obj.reply_to\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass123\"\n    assert user == \"user@hotmail.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    #\n    # Test Port Over-Riding\n    #\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://abc:password@xyz.cn:465?smtp=smtp.exmail.qq.com&mode=ssl\"\n    )\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    # Verify our over-rides are in place\n    assert obj.smtp_host == \"smtp.exmail.qq.com\"\n    assert obj.port == 465\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"abc@xyz.cn\"\n    assert obj.secure_mode == \"ssl\"\n    # No entries in the reply_to\n    assert not obj.reply_to\n\n    # No from= used in the above\n    assert re.match(r\".*from=.*\", obj.url()) is None\n    # No Our secure connection is SSL\n    assert re.match(r\".*mode=ssl.*\", obj.url()) is not None\n    # No smtp= as the SMTP server is the same as the hostname in this case\n    assert re.match(r\".*smtp=smtp.exmail.qq.com.*\", obj.url()) is not None\n    # URL is assembled based on provided user (:465 is dropped because it\n    # is a default port when using xyz.cn)\n    assert (\n        re.match(r\"^mailtos://abc:password@xyz.cn/.*\", obj.url()) is not None\n    )\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 1\n    assert response.starttls.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"password\"\n    assert user == \"abc\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://abc:password@xyz.cn?\"\n        \"smtp=smtp.exmail.qq.com&mode=ssl&port=465\"\n    )\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    # Verify our over-rides are in place\n    assert obj.smtp_host == \"smtp.exmail.qq.com\"\n    assert obj.port == 465\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"abc@xyz.cn\"\n    assert obj.secure_mode == \"ssl\"\n    # No entries in the reply_to\n    assert not obj.reply_to\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 1\n    assert response.starttls.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"password\"\n    assert user == \"abc\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    #\n    # Test Reply-To Email\n    #\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass@example.com?reply=noreply@example.com\"\n    )\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    # Verify our over-rides are in place\n    assert obj.smtp_host == \"example.com\"\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"user@example.com\"\n    assert obj.secure_mode == \"starttls\"\n    assert obj.url().startswith(\"mailtos://user:pass@example.com\")\n    # Test that our template over-ride worked\n    assert \"reply=noreply%40example.com\" in obj.url()\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass\"\n    assert user == \"user\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    #\n    # Test Reply-To Email with Name Inline\n    #\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass@example.com?reply=Chris<noreply@example.ca>\"\n    )\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    # Verify our over-rides are in place\n    assert obj.smtp_host == \"example.com\"\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"user@example.com\"\n    assert obj.secure_mode == \"starttls\"\n    assert obj.url().startswith(\"mailtos://user:pass@example.com\")\n    # Test that our template over-ride worked\n    assert \"reply=Chris+%3Cnoreply%40example.ca%3E\" in obj.url()\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"pass\"\n    assert user == \"user\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    # Fast Mail Handling\n\n    # Test variations of username required to be an email address\n    # user@example.com; we also test an over-ride port on a template driven\n    # mailto:// entry\n    results = email.NotifyEmail.parse_url(\n        \"mailto://fastmail.com/?to=hello@concordium-explorer.nl\"\n        \"&user=joe@mydomain.nl&pass=abc123\"\n        \"&from=Concordium Explorer Bot<bot@concordium-explorer.nl>\"\n    )\n    assert isinstance(results, dict)\n    assert (\n        results[\"from_addr\"]\n        == \"Concordium Explorer Bot<bot@concordium-explorer.nl>\"\n    )\n    assert results[\"user\"] == \"joe@mydomain.nl\"\n    assert results[\"port\"] is None\n    assert results[\"host\"] == \"fastmail.com\"\n    assert results[\"password\"] == \"abc123\"\n    assert \"hello@concordium-explorer.nl\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 1\n    assert response.starttls.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"bot@concordium-explorer.nl\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"hello@concordium-explorer.nl\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"abc123\"\n    assert user == \"joe@mydomain.nl\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    # Issue github.com/caronc/apprise/issue/1040\n    #  mailto://fastmail.com?user=username@customdomain.com \\\n    #          &to=username@customdomain.com&pass=password123\n    #\n    # should just have to be written like (to= omitted)\n    #  mailto://fastmail.com?user=username@customdomain.com&pass=password123\n    #\n    results = email.NotifyEmail.parse_url(\n        \"mailto://fastmail.com?user=username@customdomain.com&pass=password123\"\n    )\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"username@customdomain.com\"\n    assert results[\"from_addr\"] == \"\"\n    assert results[\"port\"] is None\n    assert results[\"host\"] == \"fastmail.com\"\n    assert results[\"password\"] == \"password123\"\n    assert results[\"smtp_host\"] == \"\"\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    # During instantiation, our variables get detected\n    assert obj.smtp_host == \"smtp.fastmail.com\"\n    assert obj.from_addr == [\"Apprise\", \"username@customdomain.com\"]\n    assert obj.host == \"customdomain.com\"\n    # detected from\n    assert (False, \"username@customdomain.com\") in obj.targets\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 1\n    assert response.starttls.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"username@customdomain.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"username@customdomain.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"password123\"\n    assert user == \"username@customdomain.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    # Similar test as above, just showing that we can over-ride the From=\n    # with these custom URLs as well and not require a full email\n    results = email.NotifyEmail.parse_url(\n        \"mailto://fastmail.com?user=username@customdomain.com\"\n        \"&pass=password123&from=Custom\"\n    )\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"username@customdomain.com\"\n    assert results[\"from_addr\"] == \"Custom\"\n    assert results[\"port\"] is None\n    assert results[\"host\"] == \"fastmail.com\"\n    assert results[\"password\"] == \"password123\"\n    assert results[\"smtp_host\"] == \"\"\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n    # During instantiation, our variables get detected\n    assert obj.smtp_host == \"smtp.fastmail.com\"\n    assert obj.from_addr == [\"Custom\", \"username@customdomain.com\"]\n    assert obj.host == \"customdomain.com\"\n    # detected from\n    assert (False, \"username@customdomain.com\") in obj.targets\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 1\n    assert response.starttls.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"username@customdomain.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"username@customdomain.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    user, pw = response.login.call_args[0]\n    assert pw == \"password123\"\n    assert user == \"username@customdomain.com\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    # Issue github.com/caronc/apprise/issue/941\n\n    # mail domain = mail-domain.com\n    # host domain = domain.subdomain.com\n    # PASSWORD needs to be fetched since a user= was provided\n    #  - this is an edge case that is tested here\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://PASSWORD@domain.subdomain.com:587?\"\n        \"user=admin@mail-domain.com&to=mail@mail-domain.com\"\n    )\n    assert isinstance(results, dict)\n    # From_Addr could not be detected at this stage, but will be\n    # handled during instantiation\n    assert results[\"from_addr\"] == \"\"\n    assert results[\"user\"] == \"admin@mail-domain.com\"\n    assert results[\"port\"] == 587\n    assert results[\"host\"] == \"domain.subdomain.com\"\n    assert results[\"password\"] == \"PASSWORD\"\n    assert \"mail@mail-domain.com\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    # Not that our from_address takes on 'admin@domain.subdomain.com'\n    assert obj.from_addr == [\"Apprise\", \"admin@domain.subdomain.com\"]\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert response.starttls.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"admin@domain.subdomain.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"mail@mail-domain.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    user, pw = response.login.call_args[0]\n    assert user == \"admin@mail-domain.com\"\n    assert pw == \"PASSWORD\"\n\n\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_plus_in_toemail(mock_smtp, mock_smtp_ssl):\n    \"\"\"NotifyEmail() support + in To Email address.\"\"\"\n\n    response = mock.Mock()\n    mock_smtp_ssl.return_value = response\n    mock_smtp.return_value = response\n\n    # We want to test the case where a + is found in the To address; we want to\n    # ensure that it is supported\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@gmail.com\"\n        \"?to=Plus Support<test+notification@gmail.com>\"\n    )\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"user\"\n    assert results[\"host\"] == \"gmail.com\"\n    assert results[\"password\"] == \"pass123\"\n    assert results[\"port\"] is None\n    assert \"Plus Support<test+notification@gmail.com>\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert len(obj.targets) == 1\n    assert (\"Plus Support\", \"test+notification@gmail.com\") in obj.targets\n    assert obj.smtp_host == \"smtp.gmail.com\"\n    assert obj.from_addr == [\"Apprise\", \"user@gmail.com\"]\n    assert obj.host == \"gmail.com\"\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"user@gmail.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"test+notification@gmail.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    #\n    # Perform the same test where the To field jsut contains the + in the\n    # address\n    #\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@gmail.com?to=test+notification@gmail.com\"\n    )\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"user\"\n    assert results[\"host\"] == \"gmail.com\"\n    assert results[\"password\"] == \"pass123\"\n    assert results[\"port\"] is None\n    assert \"test+notification@gmail.com\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert len(obj.targets) == 1\n    assert (False, \"test+notification@gmail.com\") in obj.targets\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"user@gmail.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"test+notification@gmail.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    #\n    # Perform the same test where the To field is in the URL itself\n    #\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user:pass123@gmail.com/test+notification@gmail.com\"\n    )\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"user\"\n    assert results[\"host\"] == \"gmail.com\"\n    assert results[\"password\"] == \"pass123\"\n    assert results[\"port\"] is None\n    assert \"test+notification@gmail.com\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert len(obj.targets) == 1\n    assert (False, \"test+notification@gmail.com\") in obj.targets\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"test\") is True\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"user@gmail.com\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"test+notification@gmail.com\"\n    assert msg.split(\"\\n\")[-3] == \"test\"\n\n\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_formatting_990(mock_smtp, mock_smtp_ssl):\n    \"\"\"\n    NotifyEmail() GitHub Issue 990\n    https://github.com/caronc/apprise/issues/990\n    Email formatting not working correctly\n\n    \"\"\"\n\n    response = mock.Mock()\n    mock_smtp_ssl.return_value = response\n    mock_smtp.return_value = response\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://mydomain.com?smtp=mail.local.mydomain.com\"\n        \"&user=noreply@mydomain.com&pass=mypassword\"\n        \"&from=noreply@mydomain.com&to=me@mydomain.com&mode=ssl&port=465\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"noreply@mydomain.com\"\n    assert results[\"host\"] == \"mydomain.com\"\n    assert results[\"smtp_host\"] == \"mail.local.mydomain.com\"\n    assert results[\"password\"] == \"mypassword\"\n    assert results[\"secure_mode\"] == \"ssl\"\n    assert results[\"port\"] == \"465\"\n    assert \"me@mydomain.com\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert len(obj.targets) == 1\n    assert (False, \"me@mydomain.com\") in obj.targets\n\n\ndef test_plugin_email_variables_1087():\n    \"\"\"\n    NotifyEmail() GitHub Issue 1087\n    https://github.com/caronc/apprise/issues/1087\n    Email variables reported not working correctly\n\n    \"\"\"\n\n    # Valid Configuration\n    result, _ = ConfigBase.config_parse(\n        cleandoc(\"\"\"\n    #\n    # Test Email Parsing\n    #\n    urls:\n      - mailtos://alt.lan/:\n        - user: testuser@alt.lan\n          pass: xxxxXXXxxx\n          smtp: smtp.alt.lan\n          to: alteriks@alt.lan\n    \"\"\"),\n        asset=AppriseAsset(),\n    )\n\n    assert isinstance(result, list)\n    assert len(result) == 1\n\n    email_ = result[0]\n    assert email_.from_addr == [\"Apprise\", \"testuser@alt.lan\"]\n    assert email_.user == \"testuser@alt.lan\"\n    assert email_.smtp_host == \"smtp.alt.lan\"\n    assert email_.targets == [(False, \"alteriks@alt.lan\")]\n    assert email_.password == \"xxxxXXXxxx\"\n\n    # Valid Configuration\n    result, _ = ConfigBase.config_parse(\n        cleandoc(\"\"\"\n    #\n    # Test Email Parsing where qsd over-rides all\n    #\n    urls:\n      - mailtos://alt.lan/?pass=abcd&user=joe@alt.lan:\n        - user: testuser@alt.lan\n          pass: xxxxXXXxxx\n          smtp: smtp.alt.lan\n          to: alteriks@alt.lan\n    \"\"\"),\n        asset=AppriseAsset(),\n    )\n\n    assert isinstance(result, list)\n    assert len(result) == 1\n\n    email_ = result[0]\n    assert email_.from_addr == [\"Apprise\", \"joe@alt.lan\"]\n    assert email_.user == \"joe@alt.lan\"\n    assert email_.smtp_host == \"smtp.alt.lan\"\n    assert email_.targets == [(False, \"alteriks@alt.lan\")]\n    assert email_.password == \"abcd\"\n\n\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_to_handling_1356(mock_smtp, mock_smtp_ssl):\n    \"\"\"\n    NotifyEmail() GitHub Issue 1356\n    https://github.com/caronc/apprise/issues/1356\n    Email not correctly preparing the `to:`\n\n    \"\"\"\n\n    response = mock.Mock()\n    mock_smtp_ssl.return_value = response\n    mock_smtp.return_value = response\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://smtp-relay.gmail.com?\"\n        \"from=user@custom-domain.casa&to=alerts@anothercustomdomain.net\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"host\"] == \"smtp-relay.gmail.com\"\n    assert results[\"port\"] is None\n    assert results[\"from_addr\"] == \"user@custom-domain.casa\"\n    assert results[\"smtp_host\"] == \"\"\n    assert \"alerts@anothercustomdomain.net\" in results[\"targets\"]\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert len(obj.targets) == 1\n    assert (False, \"alerts@anothercustomdomain.net\") in obj.targets\n\n    assert obj.smtp_host == \"smtp-relay.gmail.com\"\n    assert obj.from_addr == (False, \"user@custom-domain.casa\")\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"body\", \"title\") is True\n\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    # No login occurs as no user/pass was provided\n    assert response.login.call_count == 0\n    assert response.sendmail.call_count == 1\n\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"user@custom-domain.casa\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"alerts@anothercustomdomain.net\"\n    assert msg.split(\"\\n\")[-3] == \"body\"\n\n\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_variables_1334(mock_smtp, mock_smtp_ssl):\n    \"\"\"\n    NotifyEmail() GitHub Issue 1334\n    https://github.com/caronc/apprise/issues/1334\n    Localhost & Local Domain default user\n\n    \"\"\"\n\n    response = mock.Mock()\n    mock_smtp_ssl.return_value = response\n    mock_smtp.return_value = response\n\n    results = email.NotifyEmail.parse_url(\"mailto://localhost\")\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"port\"] is None\n    assert results[\"from_addr\"] == \"\"\n    assert results[\"smtp_host\"] == \"\"\n    assert results[\"targets\"] == []\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert len(obj.targets) == 1\n    assert (False, \"root@localhost\") in obj.targets\n\n    assert obj.smtp_host == \"localhost\"\n    assert obj.secure is False\n    assert obj.from_addr == [False, \"root@localhost\"]\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"body\", \"title\") is True\n\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 0\n    # No login occurs as no user/pass was provided\n    assert response.login.call_count == 0\n    assert response.sendmail.call_count == 1\n\n    #\n    # Again, but a different variation of the localhost domain\n    #\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://user@localhost.localdomain\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"user\"\n    assert results[\"password\"] is None\n    assert results[\"host\"] == \"localhost.localdomain\"\n    assert results[\"port\"] is None\n    assert results[\"from_addr\"] == \"\"\n    assert results[\"smtp_host\"] == \"\"\n    assert results[\"targets\"] == []\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail)\n\n    assert len(obj.targets) == 1\n    assert (False, \"user@localhost.localdomain\") in obj.targets\n\n    assert obj.smtp_host == \"localhost.localdomain\"\n    assert obj.secure is True\n    assert obj.from_addr == [\"Apprise\", \"user@localhost.localdomain\"]\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"body\", \"title\") is True\n\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    # No login occurs as no user/pass was provided\n    assert response.login.call_count == 0\n    assert response.sendmail.call_count == 1\n\n\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_host_detection_from_source_email(mock_smtp, mock_smtp_ssl):\n    \"\"\"NotifyEmail() Discord Issue reporting that the following did not work:\n\n    mailtos://?smtp=mobile.charter.net&pass=password&user=name@spectrum.net\n    \"\"\"\n\n    response = mock.Mock()\n    mock_smtp_ssl.return_value = response\n    mock_smtp.return_value = response\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://spectrum.net?smtp=mobile.charter.net\"\n        \"&pass=password&user=name@spectrum.net\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"name@spectrum.net\"\n    assert results[\"host\"] == \"spectrum.net\"\n    assert results[\"smtp_host\"] == \"mobile.charter.net\"\n    assert results[\"password\"] == \"password\"\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail) is True\n\n    assert len(obj.targets) == 1\n    assert (False, \"name@spectrum.net\") in obj.targets\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"name@spectrum.net\"\n    assert obj.password == \"password\"\n    assert obj.user == \"name@spectrum.net\"\n    assert obj.secure is True\n    assert obj.port == 587\n    assert obj.smtp_host == \"mobile.charter.net\"\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"body\", \"title\") is True\n\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"name@spectrum.net\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"name@spectrum.net\"\n    assert msg.split(\"\\n\")[-3] == \"body\"\n\n    #\n    # Now let's do a shortened version of the same URL where the host isn't\n    # specified but is parseable from he user login\n    #\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://?smtp=mobile.charter.net\"\n        \"&pass=password&user=name@spectrum.net\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"name@spectrum.net\"\n    assert results[\"host\"] == \"\"  # No hostname defined; it's detected later\n    assert results[\"smtp_host\"] == \"mobile.charter.net\"\n    assert results[\"password\"] == \"password\"\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail) is True\n\n    assert len(obj.targets) == 1\n    assert (False, \"name@spectrum.net\") in obj.targets\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"name@spectrum.net\"\n    assert obj.password == \"password\"\n    assert obj.user == \"name@spectrum.net\"\n    assert obj.secure is True\n    assert obj.port == 587\n    assert obj.smtp_host == \"mobile.charter.net\"\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"body\", \"title\") is True\n\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"name@spectrum.net\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"name@spectrum.net\"\n    assert msg.split(\"\\n\")[-3] == \"body\"\n\n    #\n    # Now let's do a shortened version of the same URL where the host isn't\n    # specified but is parseable from he user login\n    #\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://?smtp=mobile.charter.net\"\n        \"&pass=password&user=userid-without-domain\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"userid-without-domain\"\n    assert results[\"host\"] == \"\"  # No hostname defined\n    assert results[\"smtp_host\"] == \"mobile.charter.net\"\n    assert results[\"password\"] == \"password\"\n\n    with pytest.raises(TypeError):\n        # We will fail\n        Apprise.instantiate(results, suppress_exceptions=False)\n\n    #\n    # Now support target emails in place of the hostname\n    #\n\n    mock_smtp.reset_mock()\n    mock_smtp_ssl.reset_mock()\n    response.reset_mock()\n\n    results = email.NotifyEmail.parse_url(\n        \"mailtos://John Doe<john%40yahoo.ca>?smtp=mobile.charter.net\"\n        \"&pass=password&user=name@spectrum.net\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"name@spectrum.net\"\n    assert results[\"host\"] == \"\"  # No hostname defined; it's detected later\n    assert results[\"smtp_host\"] == \"mobile.charter.net\"\n    assert results[\"password\"] == \"password\"\n\n    obj = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(obj, email.NotifyEmail) is True\n\n    assert len(obj.targets) == 1\n    assert (\"John Doe\", \"john@yahoo.ca\") in obj.targets\n    assert obj.from_addr[0] == obj.app_id\n    assert obj.from_addr[1] == \"name@spectrum.net\"\n    assert obj.password == \"password\"\n    assert obj.user == \"name@spectrum.net\"\n    assert obj.secure is True\n    assert obj.port == 587\n    assert obj.smtp_host == \"mobile.charter.net\"\n\n    assert mock_smtp.call_count == 0\n    assert mock_smtp_ssl.call_count == 0\n    assert obj.notify(\"body\", \"title\") is True\n\n    assert mock_smtp.call_count == 1\n    assert mock_smtp_ssl.call_count == 0\n    assert response.starttls.call_count == 1\n    assert response.login.call_count == 1\n    assert response.sendmail.call_count == 1\n    # Store our Sent Arguments\n    # Syntax is:\n    #  sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=())\n    #             [0]        [1]     [2]\n    from_ = response.sendmail.call_args[0][0]\n    to = response.sendmail.call_args[0][1]\n    msg = response.sendmail.call_args[0][2]\n    assert from_ == \"name@spectrum.net\"\n    assert isinstance(to, list)\n    assert len(to) == 1\n    assert to[0] == \"john@yahoo.ca\"\n    assert msg.split(\"\\n\")[-3] == \"body\"\n\n\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_by_ipaddr_1113(mock_smtp, mock_smtp_ssl):\n    \"\"\"\n    NotifyEmail() GitHub Issue 1113\n    https://github.com/caronc/apprise/issues/1113\n    Email with ip addresses not working\n\n    \"\"\"\n\n    response = mock.Mock()\n    mock_smtp_ssl.return_value = response\n    mock_smtp.return_value = response\n\n    results = email.NotifyEmail.parse_url(\n        \"mailto://10.0.0.195:25/?to=alerts@example.com&from=sender@example.com\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"host\"] == \"10.0.0.195\"\n    assert results[\"from_addr\"] == \"sender@example.com\"\n    assert isinstance(results[\"targets\"], list)\n    assert len(results[\"targets\"]) == 1\n    assert results[\"targets\"][0] == \"alerts@example.com\"\n    assert results[\"port\"] == 25\n\n    email_ = Apprise.instantiate(results, suppress_exceptions=False)\n    assert isinstance(email_, email.NotifyEmail) is True\n\n    assert len(email_.targets) == 1\n    assert (False, \"alerts@example.com\") in email_.targets\n\n    assert email_.from_addr == (False, \"sender@example.com\")\n    assert email_.user is None\n    assert email_.password is None\n    assert email_.smtp_host == \"10.0.0.195\"\n    assert email_.port == 25\n    assert email_.targets == [(False, \"alerts@example.com\")]\n\n\n@pytest.mark.skipif(\"pgpy\" not in sys.modules, reason=\"Requires PGPy\")\n@mock.patch(\"smtplib.SMTP_SSL\")\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_pgp(mock_smtp, mock_smtpssl, tmpdir):\n    \"\"\"NotifyEmail() PGP Tests.\"\"\"\n    # Our mock of our socket action\n    mock_socket = mock.Mock()\n    mock_socket.starttls.return_value = True\n    mock_socket.login.return_value = True\n\n    # Create a mock SMTP Object\n    mock_smtp.return_value = mock_socket\n    mock_smtpssl.return_value = mock_socket\n\n    assert utils.pgp.PGP_SUPPORT is True\n    utils.pgp.PGP_SUPPORT = False\n    # Forces to run through section of code that produces a warning there is\n    # no PGP\n    obj = Apprise.instantiate(\"mailto://user:pass@nuxref.com?pgp=yes\")\n    # No PGP Support and set enabled\n    assert obj.notify(\"test body\") is False\n\n    # Return the PGP status for remaining checks\n    utils.pgp.PGP_SUPPORT = True\n\n    # Initialize our email (no from name)\n    obj = Apprise.instantiate(\"mailto://user2:pass@nuxref.com?pgp=yes\")\n\n    # Nothing to lookup\n    assert obj.pgp.public_keyfile() is None\n    assert obj.pgp.public_key() is None\n    assert obj.pgp.encrypt(\"message\") is False\n    # Keys can not be generated in memory mode\n    assert obj.pgp.keygen() is False\n\n    # The reason... no location to store data\n    assert obj.store.mode == PersistentStoreMode.MEMORY\n\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir0),\n    )\n\n    # Prepare PGP\n    obj = Apprise.instantiate(\n        \"mailto://pgp:pass@nuxref.com?pgp=yes\", asset=asset\n    )\n    assert obj.store.mode == PersistentStoreMode.FLUSH\n\n    # Still no public key\n    assert obj.pgp.public_key(autogen=False) is None\n    assert obj.pgp.keygen() is True\n    # Now we'll have a public key\n    assert isinstance(obj.pgp.public_keyfile(), str)\n\n    # Generate warning by second call\n    assert obj.pgp.keygen() is True\n\n    # Remove newly generated files\n    os.unlink(os.path.join(obj.store.path, \"pgp-pub.asc\"))\n    os.unlink(os.path.join(obj.store.path, \"pgp-prv.asc\"))\n    obj = Apprise.instantiate(\n        \"mailto://pgp:pass@nuxref.com?pgp=yes\", asset=asset\n    )\n    assert obj.store.mode == PersistentStoreMode.FLUSH\n    assert obj.pgp.keygen() is True\n\n    # Prepare PGP while providing it a key\n    obj = Apprise.instantiate(\n        \"mailto://pgp:pass@nuxref.com?pgp=yes&\"\n        f\"pgpkey={obj.pgp.public_keyfile()}\",\n        asset=asset,\n    )\n\n    # keyfile Defined\n    assert obj.pgp.pub_keyfile is not None\n\n    # Get our key\n    key = obj.pgp.public_key()\n\n    # In this circumstance we can not generate a new key as the one provided\n    # is immutable\n    assert obj.pgp.keygen() is False\n\n    # Our key is the same\n    assert key is obj.pgp.public_key()\n\n    tmpdir0 = tmpdir.mkdir(\"tmp00a\")\n    asset0 = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir0),\n    )\n\n    # Prepare Invalid PGP Key\n    obj = Apprise.instantiate(\n        \"mailto://pgpX:pass@nuxref.com?pgp=yes\", asset=asset0\n    )\n\n    # No keyfiles\n    assert obj.pgp.pub_keyfile is None\n\n    # Generate our keys\n    assert obj.pgp.keygen() is True\n\n    # Second call uses cache\n    assert obj.pgp.keygen() is True\n\n    # We will find our key\n    key = obj.pgp.public_key()\n    assert key is not None\n\n    # Utilize force parameter\n    assert obj.pgp.keygen(force=True) is True\n\n    # Our key is new\n    assert key != obj.pgp.public_key()\n    assert obj.pgp.public_key() is not None\n\n    # Prepare Invalid PGP Key\n    obj = Apprise.instantiate(\n        \"mailto://pgp:pass@nuxref.com?pgp=yes&pgpkey=invalid\", asset=asset\n    )\n\n    # Returns false\n    assert obj.pgp.pub_keyfile is False\n    assert obj.pgp.public_keyfile() is False\n\n    tmpdir2 = tmpdir.mkdir(\"tmp02\")\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir2),\n    )\n    obj = Apprise.instantiate(\n        \"mailto://chris:pass@nuxref.com?pgp=yes\", asset=asset\n    )\n\n    assert obj.store.mode == PersistentStoreMode.FLUSH\n    assert obj.pgp.keygen() is True\n\n    # Second call uses cache\n    assert obj.pgp.keygen() is True\n\n    # We will find our key\n    assert obj.pgp.public_key() is not None\n\n    # We do this again but even when we do a requisition for a public key\n    # it will generate a new pair or keys for us once it detects we don't\n    # have any\n    tmpdir3 = tmpdir.mkdir(\"tmp03\")\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir3),\n    )\n    obj = Apprise.instantiate(\n        \"mailto://chris:pass@nuxref.com/user@example.com?pgp=yes\", asset=asset\n    )\n\n    assert obj.store.mode == PersistentStoreMode.FLUSH\n\n    # We'll have a public key object to encrypt with\n    assert obj.pgp.public_key() is not None\n\n    encrypted = obj.pgp.encrypt(\"hello world\")\n    assert encrypted.startswith(\"-----BEGIN PGP MESSAGE-----\")\n    assert encrypted.rstrip().endswith(\"-----END PGP MESSAGE-----\")\n\n    dir_content = os.listdir(obj.store.path)\n    assert \"chris-pub.asc\" in dir_content\n    assert \"chris-prv.asc\" in dir_content\n\n    assert obj.pgp.public_keyfile().endswith(\"chris-pub.asc\")\n\n    assert obj.notify(\"test body\") is True\n\n    # The private key is not needed for sending the encrypted messages\n    os.unlink(os.path.join(obj.store.path, \"chris-prv.asc\"))\n    os.rename(\n        os.path.join(obj.store.path, \"chris-pub.asc\"),\n        os.path.join(obj.store.path, \"user@example.com-pub.asc\"),\n    )\n\n    assert obj.pgp.public_keyfile() is None\n    assert obj.pgp.public_keyfile(\"not-reference@example.com\") is None\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\n        \"user@example.com-pub.asc\"\n    )\n\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\n        \"user@example.com-pub.asc\"\n    )\n    assert obj.pgp.public_keyfile(\"User@Example.com\").endswith(\n        \"user@example.com-pub.asc\"\n    )\n    assert obj.pgp.public_keyfile(\"unknown\") is None\n\n    shutil.copyfile(\n        os.path.join(obj.store.path, \"user@example.com-pub.asc\"),\n        os.path.join(obj.store.path, \"user-pub.asc\"),\n    )\n\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\n        \"user@example.com-pub.asc\"\n    )\n    assert obj.pgp.public_keyfile(\"User@Example.com\").endswith(\n        \"user@example.com-pub.asc\"\n    )\n\n    # Remove file\n    os.unlink(os.path.join(obj.store.path, \"user@example.com-pub.asc\"))\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\"user-pub.asc\")\n    shutil.copyfile(\n        os.path.join(obj.store.path, \"user-pub.asc\"),\n        os.path.join(obj.store.path, \"chris-pub.asc\"),\n    )\n    # user-pub.asc still trumps still trumps\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\"user-pub.asc\")\n    shutil.copyfile(\n        os.path.join(obj.store.path, \"chris-pub.asc\"),\n        os.path.join(obj.store.path, \"chris@nuxref.com-pub.asc\"),\n    )\n    # user-pub still trumps\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\"user-pub.asc\")\n    assert obj.pgp.public_keyfile(\"invalid@example.com\").endswith(\n        \"chris@nuxref.com-pub.asc\"\n    )\n\n    # remove this file\n    os.unlink(os.path.join(obj.store.path, \"user-pub.asc\"))\n\n    # now we fall back to basic/default configuration\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\n        \"chris@nuxref.com-pub.asc\"\n    )\n    os.unlink(os.path.join(obj.store.path, \"chris@nuxref.com-pub.asc\"))\n    assert obj.pgp.public_keyfile(\"user@example.com\").endswith(\"chris-pub.asc\")\n\n    # Testing again\n    tmpdir4 = tmpdir.mkdir(\"tmp04\")\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir4),\n    )\n    obj = Apprise.instantiate(\n        \"mailto://chris:pass@nuxref.com/user@example.com?pgp=yes\", asset=asset\n    )\n\n    with mock.patch(\"builtins.open\", side_effect=FileNotFoundError):\n        # can't open key\n        assert obj.pgp.public_key() is None\n\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        # can't open key\n        assert obj.pgp.public_key() is None\n        # Test unlink\n        with mock.patch(\"os.unlink\", side_effect=OSError):\n            assert obj.pgp.public_key() is None\n\n        # Key Generation will fail\n        assert obj.pgp.keygen() is False\n\n    with mock.patch(\"pgpy.PGPKey.new\", side_effect=NameError):\n        # Can't Generate keys\n        assert obj.pgp.keygen() is False\n        # can't open key\n        assert obj.pgp.public_key() is None\n\n    with mock.patch(\"pgpy.PGPKey.from_blob\", side_effect=FileNotFoundError):\n        # can't open key\n        assert obj.pgp.public_key() is None\n\n    with mock.patch(\"pgpy.PGPKey.from_blob\", side_effect=OSError):\n        # can't open key\n        assert obj.pgp.public_key() is None\n\n    # Can't encrypt key\n    with mock.patch(\"pgpy.PGPKey.from_blob\", side_effect=NameError):\n        assert obj.pgp.public_key() is None\n\n    with mock.patch(\"pgpy.PGPMessage.new\", side_effect=NameError):\n        assert obj.pgp.encrypt(\"message\") is None\n        # Attempts to encrypt a message\n        assert obj.notify(\"test-encrypt\") is False\n\n    # Create new keys\n    assert obj.pgp.keygen() is True\n    with (\n        mock.patch(\"os.path.isfile\", return_value=False),\n        mock.patch(\"builtins.open\", side_effect=OSError),\n        mock.patch(\"os.unlink\", return_value=None),\n    ):\n        assert obj.pgp.keygen() is False\n\n    # Testing again\n    tmpdir5 = tmpdir.mkdir(\"tmp05\")\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir5),\n    )\n    obj = Apprise.instantiate(\n        \"mailto://chris:pass@nuxref.com/user@example.com?pgp=yes\", asset=asset\n    )\n\n    # Catch edge case where we just can't generate the the key\n    with (\n        mock.patch(\n            \"os.path.isfile\",\n            side_effect=(\n                # 5x False to skip through pgp.public_keyfile()\n                False,\n                False,\n                False,\n                False,\n                False,\n                False,\n                # 1x True to pass pgp.keygen()\n                True,\n                # 5x False to skip through pgp.public_keyfile() second call\n                False,\n                False,\n                False,\n                False,\n                False,\n                False,\n            ),\n        ),\n        mock.patch(\"pgpy.PGPKey.from_blob\", side_effect=FileNotFoundError),\n    ):\n        assert obj.pgp.public_key() is None\n\n    # Corrupt Data\n    tmpdir6 = tmpdir.mkdir(\"tmp06\")\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir6),\n    )\n    obj = Apprise.instantiate(\n        \"mailto://chris:pass@nuxref.com/user@example.com?pgp=yes\", asset=asset\n    )\n\n    shutil.copyfile(\n        os.path.join(TEST_VAR_DIR, \"pgp\", \"corrupt-pub.asc\"),\n        os.path.join(obj.store.path, \"chris-pub.asc\"),\n    )\n\n    # Key is corrupted\n    assert obj.notify(\"test\") is False\n\n    shutil.copyfile(\n        os.path.join(TEST_VAR_DIR, \"apprise-test.jpeg\"),\n        os.path.join(obj.store.path, \"chris-pub.asc\"),\n    )\n\n    # Key is a binary image; definitely not a valid key\n    assert obj.notify(\"test\") is False\n\n\n@pytest.mark.skipif(\"pgpy\" not in sys.modules, reason=\"Requires PGPy\")\ndef test_plugin_email_prepare():\n    \"\"\"NotifyEmail() prepare_emails static function.\"\"\"\n    with pytest.raises(AppriseException):\n        # No To: provided\n        for _e in email.NotifyEmail.prepare_emails(\n            subject=\"Email Subject\",\n            body=\"Email Body\",\n            from_addr=(None, \"test@test.com\"),\n            to=[],\n        ):\n            pass\n\n    # Most basic call (a lot of defaults are used)\n    iterator = email.NotifyEmail.prepare_emails(\n        subject=\"Email Subject\",\n        body=\"Email Body\",\n        from_addr=(None, \"test@test.com\"),\n        to=[\n            (\"Apprise User\", \"apprise@test.com\"),\n        ],\n    )\n    entries = list(iterator)\n    assert len(entries) == 1\n\n\n@pytest.mark.skipif(\"pgpy\" not in sys.modules, reason=\"Requires PGPy\")\ndef test_plugin_pgp(tmpdir):\n    \"\"\"Pretty Good Privacy Testing.\"\"\"\n\n    p_obj = utils.pgp.ApprisePGPController(path=None)\n    # No Path\n    assert p_obj.keygen() is False\n    assert p_obj.public_keyfile() is None\n\n    p_obj = utils.pgp.ApprisePGPController(path=None, email=\"l2g@email.com\")\n    # No Path\n    assert p_obj.keygen() is False\n\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n    p_obj = utils.pgp.ApprisePGPController(\n        path=str(tmpdir0), email=\"l2g@email.com\"\n    )\n\n    # A key can be generated with a path defined\n    assert p_obj.keygen() is True\n    assert p_obj.public_keyfile() is not None\n    # A key can be generated with a path defined\n    assert p_obj.keygen(name=\"Apprise\", force=True) is True\n    assert (\n        p_obj.keygen(email=\"l2g@email.com\", name=\"Apprise\", force=True) is True\n    )\n\n    assert utils.pgp.PGP_SUPPORT is True\n    utils.pgp.PGP_SUPPORT = False\n\n    with pytest.raises(AppriseException):\n        assert p_obj.public_keyfile()\n\n    # Return the PGP status for remaining checks\n    utils.pgp.PGP_SUPPORT = True\n\n    tmpdir1 = tmpdir.mkdir(\"tmp01\")\n    p_obj = utils.pgp.ApprisePGPController(\n        path=str(tmpdir1), pub_keyfile=\"bad-file\"\n    )\n    assert p_obj.public_keyfile() is False\n\n\n@pytest.mark.skipif(\"pgpy\" not in sys.modules, reason=\"Requires PGPy\")\ndef test_pgp_public_keyfile_skips_self_email(tmpdir):\n    \"\"\"Test pgp.public_keyfile() when self.email is None and skipped.\"\"\"\n\n    key_dir = tmpdir.mkdir(\"pgpkeytest2\")\n    key_path = os.path.join(str(key_dir), \"externaluser-pub.asc\")\n\n    # Create a fake matching keyfile to trigger discovery\n    with open(key_path, \"w\") as f:\n        f.write(\"-----BEGIN PGP PUBLIC KEY BLOCK-----\\n\")\n\n    # Controller without setting self.email\n    pgp = utils.pgp.ApprisePGPController(path=str(key_dir), email=None)\n\n    # Should skip over `if self.email:` logic entirely\n    result = pgp.public_keyfile(\"externaluser@email.com\")\n\n    assert result.endswith(\"externaluser-pub.asc\")\n\n\n@mock.patch(\"smtplib.SMTP\")\ndef test_plugin_email_gmx_template_lookup(mock_smtp):\n    \"\"\"NotifyEmail() GMX template lookup tests.\"\"\"\n\n    response = mock.Mock()\n    mock_smtp.return_value = response\n\n    for domain in (\"gmx.net\", \"gmx.com\", \"gmx.de\", \"gmx.at\", \"gmx.ch\",\n                   \"gmx.fr\"):\n\n        results = email.NotifyEmail.parse_url(\n            f\"mailtos://user:pass123@{domain}\")\n        obj = Apprise.instantiate(results, suppress_exceptions=False)\n        assert isinstance(obj, email.NotifyEmail)\n\n        # Template-driven defaults\n        assert obj.smtp_host == \"mail.gmx.com\"\n        assert obj.secure_mode == \"starttls\"\n        assert obj.port == 587\n        assert obj.secure is True\n\n        # Send once to trigger SMTP/login behaviour\n        assert obj.notify(\"body\", \"title\") is True\n\n        # STARTTLS used\n        assert response.starttls.call_count == 1\n        assert response.login.call_count == 1\n\n        login_user, login_pass = response.login.call_args[0]\n        mock_smtp.reset_mock()\n\n        assert login_pass == \"pass123\"\n\n        # GMX should authenticate with full email, not just the local part\n        assert login_user == f\"user@{domain}\"\n"
  },
  {
    "path": "tests/test_plugin_emby.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.emby import NotifyEmby\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    # Insecure Request; no hostname specified\n    (\n        \"emby://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # Secure Emby Request; no hostname specified\n    (\n        \"embys://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # No user specified\n    (\n        \"emby://localhost\",\n        {\n            # Missing a username\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"emby://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # Valid Authentication\n    (\n        \"emby://l2g@localhost\",\n        {\n            \"instance\": NotifyEmby,\n            # our response will be False because our authentication can't be\n            # tested very well using this matrix.\n            \"response\": False,\n        },\n    ),\n    (\n        \"embys://l2g:password@localhost\",\n        {\n            \"instance\": NotifyEmby,\n            # our response will be False because our authentication can't be\n            # tested very well using this matrix.\n            \"response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"embys://l2g:****@localhost\",\n        },\n    ),\n)\n\n\ndef test_plugin_template_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"apprise.plugins.emby.NotifyEmby.sessions\")\n@mock.patch(\"apprise.plugins.emby.NotifyEmby.login\")\n@mock.patch(\"apprise.plugins.emby.NotifyEmby.logout\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_emby_general(\n    mock_post, mock_get, mock_logout, mock_login, mock_sessions\n):\n    \"\"\"NotifyEmby General Tests.\"\"\"\n\n    req = requests.Request()\n    req.status_code = requests.codes.ok\n    req.content = \"\"\n    mock_get.return_value = req\n    mock_post.return_value = req\n\n    # This is done so we don't obstruct our access_token and user_id values\n    mock_login.return_value = True\n    mock_logout.return_value = True\n    mock_sessions.return_value = {\"abcd\": {}}\n\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost?modal=False\")\n    assert isinstance(obj, NotifyEmby)\n    assert obj.notify(\"title\", \"body\", \"info\") is True\n    obj.access_token = \"abc\"\n    obj.user_id = \"123\"\n\n    # Test Modal support\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost?modal=True\")\n    assert isinstance(obj, NotifyEmby)\n    assert obj.notify(\"title\", \"body\", \"info\") is True\n    obj.access_token = \"abc\"\n    obj.user_id = \"123\"\n\n    # Test our exception handling\n    for exception in AppriseURLTester.req_exceptions:\n        mock_post.side_effect = exception\n        mock_get.side_effect = exception\n        # We'll fail to log in each time\n        assert obj.notify(\"title\", \"body\", \"info\") is False\n\n    # Disable Exceptions\n    mock_post.side_effect = None\n    mock_get.side_effect = None\n\n    # Our login flat out fails if we don't have proper parseable content\n    mock_post.return_value.content = \"\"\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # KeyError handling\n    mock_post.return_value.status_code = 999\n    mock_get.return_value.status_code = 999\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n\n    # General Internal Server Error\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    mock_get.return_value.status_code = requests.codes.internal_server_error\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # Disable the port completely\n    obj.port = None\n    assert obj.notify(\"title\", \"body\", \"info\") is True\n\n    # An Empty return set (no query is made, but notification will still\n    # succeed\n    mock_sessions.return_value = {}\n    assert obj.notify(\"title\", \"body\", \"info\") is True\n\n    # Tidy our object\n    del obj\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_emby_login(mock_post, mock_get):\n    \"\"\"NotifyEmby() login()\"\"\"\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost\")\n    assert isinstance(obj, NotifyEmby)\n\n    # Test our exception handling\n    for exception in AppriseURLTester.req_exceptions:\n        mock_post.side_effect = exception\n        mock_get.side_effect = exception\n        # We'll fail to log in each time\n        assert obj.login() is False\n\n    # Disable Exceptions\n    mock_post.side_effect = None\n    mock_get.side_effect = None\n\n    # Our login flat out fails if we don't have proper parseable content\n    mock_post.return_value.content = \"\"\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # KeyError handling\n    mock_post.return_value.status_code = 999\n    mock_get.return_value.status_code = 999\n    assert obj.login() is False\n\n    # General Internal Server Error\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    mock_get.return_value.status_code = requests.codes.internal_server_error\n    assert obj.login() is False\n\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost:1234\")\n    # Set a different port (outside of default)\n    assert isinstance(obj, NotifyEmby)\n    assert obj.port == 1234\n\n    # The login will fail because '' is not a parseable JSON response\n    assert obj.login() is False\n\n    # Disable the port completely\n    obj.port = None\n    assert obj.login() is False\n\n    # Default port assignments\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost\")\n    assert isinstance(obj, NotifyEmby)\n    assert obj.port == 8096\n\n    # The login will (still) fail because '' is not a parseable JSON response\n    assert obj.login() is False\n\n    # Our login flat out fails if we don't have proper parseable content\n    mock_post.return_value.content = dumps({\n        \"AccessToken\": \"0000-0000-0000-0000\",\n    })\n    mock_get.return_value.content = mock_post.return_value.content\n\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost\")\n    assert isinstance(obj, NotifyEmby)\n\n    # The login will fail because the 'User' or 'Id' field wasn't parsed\n    assert obj.login() is False\n\n    # Our text content (we intentionally reverse the 2 locations\n    # that store the same thing; we do this so we can test which\n    # one it defaults to if both are present\n    mock_post.return_value.content = dumps({\n        \"User\": {\n            \"Id\": \"abcd123\",\n        },\n        \"Id\": \"123abc\",\n        \"AccessToken\": \"0000-0000-0000-0000\",\n    })\n    mock_get.return_value.content = mock_post.return_value.content\n\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost\")\n    assert isinstance(obj, NotifyEmby)\n\n    # Login\n    assert obj.login() is True\n    assert obj.user_id == \"123abc\"\n    assert obj.access_token == \"0000-0000-0000-0000\"\n\n    # We're going to log in a second time which checks that we logout\n    # first before logging in again. But this time we'll scrap the\n    # 'Id' area and use the one found in the User area if detected\n    mock_post.return_value.content = dumps({\n        \"User\": {\n            \"Id\": \"abcd123\",\n        },\n        \"AccessToken\": \"0000-0000-0000-0000\",\n    })\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # Login\n    assert obj.login() is True\n    assert obj.user_id == \"abcd123\"\n    assert obj.access_token == \"0000-0000-0000-0000\"\n\n\n@mock.patch(\"apprise.plugins.emby.NotifyEmby.login\")\n@mock.patch(\"apprise.plugins.emby.NotifyEmby.logout\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_emby_sessions(mock_post, mock_get, mock_logout, mock_login):\n    \"\"\"NotifyEmby() sessions()\"\"\"\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n\n    # This is done so we don't obstruct our access_token and user_id values\n    mock_login.return_value = True\n    mock_logout.return_value = True\n\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost\")\n    assert isinstance(obj, NotifyEmby)\n    obj.access_token = \"abc\"\n    obj.user_id = \"123\"\n\n    # Test our exception handling\n    for exception in AppriseURLTester.req_exceptions:\n        mock_post.side_effect = exception\n        mock_get.side_effect = exception\n        # We'll fail to log in each time\n        sessions = obj.sessions()\n        assert isinstance(sessions, dict) is True\n        assert len(sessions) == 0\n\n    # Disable Exceptions\n    mock_post.side_effect = None\n    mock_get.side_effect = None\n\n    # Our login flat out fails if we don't have proper parseable content\n    mock_post.return_value.content = \"\"\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # KeyError handling\n    mock_post.return_value.status_code = 999\n    mock_get.return_value.status_code = 999\n    sessions = obj.sessions()\n    assert isinstance(sessions, dict) is True\n    assert len(sessions) == 0\n\n    # General Internal Server Error\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    mock_get.return_value.status_code = requests.codes.internal_server_error\n    sessions = obj.sessions()\n    assert isinstance(sessions, dict) is True\n    assert len(sessions) == 0\n\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # Disable the port completely\n    obj.port = None\n\n    sessions = obj.sessions()\n    assert isinstance(sessions, dict) is True\n    assert len(sessions) == 0\n\n    # Let's get some results\n    mock_post.return_value.content = dumps([\n        {\n            \"Id\": \"abc123\",\n        },\n        {\n            \"Id\": \"def456\",\n        },\n        {\n            \"InvalidEntry\": None,\n        },\n    ])\n    mock_get.return_value.content = mock_post.return_value.content\n\n    sessions = obj.sessions(user_controlled=True)\n    assert isinstance(sessions, dict) is True\n    assert len(sessions) == 2\n\n    # Test it without setting user-controlled sessions\n    sessions = obj.sessions(user_controlled=False)\n    assert isinstance(sessions, dict) is True\n    assert len(sessions) == 2\n\n    # Triggers an authentication failure\n    obj.user_id = None\n    mock_login.return_value = False\n    sessions = obj.sessions()\n    assert isinstance(sessions, dict) is True\n    assert len(sessions) == 0\n\n\n@mock.patch(\"apprise.plugins.emby.NotifyEmby.login\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_emby_logout(mock_post, mock_get, mock_login):\n    \"\"\"NotifyEmby() logout()\"\"\"\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n\n    # This is done so we don't obstruct our access_token and user_id values\n    mock_login.return_value = True\n\n    obj = Apprise.instantiate(\"emby://l2g:l2gpass@localhost\")\n    assert isinstance(obj, NotifyEmby)\n    obj.access_token = \"abc\"\n    obj.user_id = \"123\"\n\n    # Test our exception handling\n    for exception in AppriseURLTester.req_exceptions:\n        mock_post.side_effect = exception\n        mock_get.side_effect = exception\n        # We'll fail to log in each time\n        obj.logout()\n        obj.access_token = \"abc\"\n        obj.user_id = \"123\"\n\n    # Disable Exceptions\n    mock_post.side_effect = None\n    mock_get.side_effect = None\n\n    # Our login flat out fails if we don't have proper parseable content\n    mock_post.return_value.content = \"\"\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # KeyError handling\n    mock_post.return_value.status_code = 999\n    mock_get.return_value.status_code = 999\n    obj.logout()\n    obj.access_token = \"abc\"\n    obj.user_id = \"123\"\n\n    # General Internal Server Error\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    mock_get.return_value.status_code = requests.codes.internal_server_error\n    obj.logout()\n    obj.access_token = \"abc\"\n    obj.user_id = \"123\"\n\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # Disable the port completely\n    obj.port = None\n\n    # Perform logout\n    obj.logout()\n\n    # Calling logout on an object already logged out\n    obj.logout()\n\n    # Test Python v3.5 LookupError Bug: https://bugs.python.org/issue29288\n    mock_post.side_effect = LookupError()\n    mock_get.side_effect = LookupError()\n    obj.access_token = \"abc\"\n    obj.user_id = \"123\"\n\n    # Tidy object\n    del obj\n"
  },
  {
    "path": "tests/test_plugin_enigma2.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.enigma2 import NotifyEnigma2\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"enigma2://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"enigma2://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"enigma2s://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"enigma2://localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            # This will fail because we're also expecting a server\n            # acknowledgement\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"enigma2://localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            # invalid JSON response\n            \"requests_response_text\": \"{\",\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"enigma2://localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            # False is returned\n            \"requests_response_text\": {\"result\": False},\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"enigma2://localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            # With the right content, this will succeed\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    (\n        \"enigma2://user@localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    # Set timeout\n    (\n        \"enigma2://user@localhost?timeout=-1\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    # Set timeout\n    (\n        \"enigma2://user@localhost?timeout=-1000\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    # Set invalid timeout (defaults to a set value)\n    (\n        \"enigma2://user@localhost?timeout=invalid\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    (\n        \"enigma2://user:pass@localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"enigma2://user:****@localhost\",\n        },\n    ),\n    (\n        \"enigma2://localhost:8080\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    (\n        \"enigma2://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    (\n        \"enigma2s://localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    (\n        \"enigma2s://user:pass@localhost\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"enigma2s://user:****@localhost\",\n        },\n    ),\n    (\n        \"enigma2s://localhost:8080/path/\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"enigma2s://localhost:8080/path/\",\n        },\n    ),\n    (\n        \"enigma2s://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    (\n        \"enigma2://localhost:8080/path?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n        },\n    ),\n    (\n        \"enigma2://user:pass@localhost:8081\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"enigma2://user:pass@localhost:8082\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"enigma2://user:pass@localhost:8083\",\n        {\n            \"instance\": NotifyEnigma2,\n            \"requests_response_text\": {\"result\": True},\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_enigma2_urls():\n    \"\"\"NotifyEnigma2() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_fcm.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n#\n# Great Resources:\n# - Dev/Legacy API:\n#    https://firebase.google.com/docs/cloud-messaging/http-server-ref\n# - Legacy API (v1) -> OAuth\n# - https://firebase.google.com/docs/cloud-messaging/migrate-v1\n\nimport json\nimport os\nimport sys\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.fcm import NotifyFCM\n\ntry:\n    from cryptography.exceptions import UnsupportedAlgorithm\n\n    from apprise.plugins.fcm.color import FCMColorManager\n    from apprise.plugins.fcm.common import FCM_MODES\n    from apprise.plugins.fcm.oauth import GoogleOAuth\n    from apprise.plugins.fcm.priority import FCM_PRIORITIES, FCMPriorityManager\n\nexcept ImportError:\n    # No problem; there is no cryptography support\n    pass\n\n\n# Disable logging for a cleaner testing output\nimport logging\n\nlogging.disable(logging.CRITICAL)\n\n# Test files for KeyFile Directory\nPRIVATE_KEYFILE_DIR = os.path.join(os.path.dirname(__file__), \"var\", \"fcm\")\nFCM_KEYFILE = os.path.join(PRIVATE_KEYFILE_DIR, \"service_account.json\")\n\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"fcm://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"fcm://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"fcm://project@%20%20/\",\n        {\n            # invalid apikey\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"fcm://apikey/\",\n        {\n            # no project id specified so we operate in legacy mode\n            \"instance\": NotifyFCM,\n            # but there are no targets specified so we return False\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"fcm://apikey/device\",\n        {\n            # Valid device\n            \"instance\": NotifyFCM,\n            \"privacy_url\": \"fcm://a...y/device\",\n        },\n    ),\n    (\n        \"fcm://apikey/#topic\",\n        {\n            # Valid topic\n            \"instance\": NotifyFCM,\n            \"privacy_url\": \"fcm://a...y/%23topic\",\n        },\n    ),\n    (\n        \"fcm://apikey/device?mode=invalid\",\n        {\n            # Valid device, invalid mode\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"fcm://apikey/#topic1/device/%20/\",\n        {\n            # Valid topic, valid device, and invalid entry\n            \"instance\": NotifyFCM,\n        },\n    ),\n    (\n        \"fcm://apikey?to=#topic1,device\",\n        {\n            # Test to=\n            \"instance\": NotifyFCM,\n        },\n    ),\n    (\n        \"fcm://?apikey=abc123&to=device\",\n        {\n            # Test apikey= to=\n            \"instance\": NotifyFCM,\n        },\n    ),\n    (\n        \"fcm://?apikey=abc123&to=device&image=yes\",\n        {\n            # Test image boolean\n            \"instance\": NotifyFCM,\n        },\n    ),\n    (\n        \"fcm://?apikey=abc123&to=device&color=no\",\n        {\n            # Disable colors\n            \"instance\": NotifyFCM,\n        },\n    ),\n    (\n        \"fcm://?apikey=abc123&to=device&color=aabbcc\",\n        {\n            # custom colors\n            \"instance\": NotifyFCM,\n        },\n    ),\n    (\n        (\n            \"fcm://?apikey=abc123&to=device\"\n            \"&image_url=http://example.com/interesting.jpg\"\n        ),\n        {\n            # Test image_url\n            \"instance\": NotifyFCM\n        },\n    ),\n    (\n        (\n            \"fcm://?apikey=abc123&to=device\"\n            \"&image_url=http://example.com/interesting.jpg&image=no\"\n        ),\n        {\n            # Test image_url but set to no\n            \"instance\": NotifyFCM\n        },\n    ),\n    (\n        \"fcm://?apikey=abc123&to=device&+key=value&+key2=value2\",\n        {\n            # Test apikey= to= and data arguments\n            \"instance\": NotifyFCM,\n        },\n    ),\n    (\n        \"fcm://%20?to=device&keyfile=/invalid/path\",\n        {\n            # invalid Project ID\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"fcm://project_id?to=device&keyfile=/invalid/path\",\n        {\n            # Test to= and auto-detection of OAuth mode\n            \"instance\": NotifyFCM,\n            # we'll fail to send our notification as a result\n            \"response\": False,\n        },\n    ),\n    (\n        \"fcm://?to=device&project=project_id&keyfile=/invalid/path\",\n        {\n            # Test project= & to= and auto detection of OAuth mode\n            \"instance\": NotifyFCM,\n            # we'll fail to send our notification as a result\n            \"response\": False,\n        },\n    ),\n    (\n        \"fcm://project_id?to=device&mode=oauth2\",\n        {\n            # no keyfile was specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"fcm://project_id?to=device&mode=oauth2&keyfile=/invalid/path\",\n        {\n            # Same test as above except we explicitly set our oauth2 mode\n            # Test to= and auto-detection of OAuth mode\n            \"instance\": NotifyFCM,\n            # we'll fail to send our notification as a result\n            \"response\": False,\n        },\n    ),\n    (\n        \"fcm://apikey/#topic1/device/?mode=legacy\",\n        {\n            \"instance\": NotifyFCM,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"fcm://apikey/#topic1/device/?mode=legacy\",\n        {\n            \"instance\": NotifyFCM,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"fcm://project/#topic1/device/?mode=oauth2&keyfile=file://{}\".format(\n            os.path.join(\n                os.path.dirname(__file__), \"var\", \"fcm\", \"service_account.json\"\n            )\n        ),\n        {\n            \"instance\": NotifyFCM,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"fcm://projectid/#topic1/device/?mode=oauth2&keyfile=file://{}\".format(\n            os.path.join(\n                os.path.dirname(__file__), \"var\", \"fcm\", \"service_account.json\"\n            )\n        ),\n        {\n            \"instance\": NotifyFCM,\n            # Throws a series of connection and transfer exceptions when\n            # this flag is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\n@pytest.fixture\ndef mock_post(mocker):\n    \"\"\"Prepare a good OAuth mock response.\"\"\"\n\n    mock_thing = mocker.patch(\"requests.post\")\n\n    response = mock.Mock()\n    response.content = json.dumps({\n        \"access_token\": \"ya29.c.abcd\",\n        \"expires_in\": 3599,\n        \"token_type\": \"Bearer\",\n    })\n    response.status_code = requests.codes.ok\n    mock_thing.return_value = response\n\n    return mock_thing\n\n\n@pytest.fixture\ndef mock_post_legacy(mocker):\n    \"\"\"Prepare a good legacy mock response.\"\"\"\n\n    mock_thing = mocker.patch(\"requests.post\")\n\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = b\"\"\n    mock_thing.return_value = response\n\n    return mock_thing\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_urls():\n    \"\"\"NotifyFCM() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_legacy_default(mock_post_legacy):\n    \"\"\"NotifyFCM() Legacy/APIKey default checks.\"\"\"\n\n    # A valid Legacy URL\n    obj = Apprise.instantiate(\n        \"fcm://abc123/device/\"\n        \"?+key=value&+key2=value2\"\n        \"&image_url=https://example.com/interesting.png\"\n    )\n\n    # Send our notification\n    assert obj.notify(\"test\") is True\n\n    # Test our call count\n    assert mock_post_legacy.call_count == 1\n    assert (\n        mock_post_legacy.call_args_list[0][0][0]\n        == \"https://fcm.googleapis.com/fcm/send\"\n    )\n\n    payload = mock_post_legacy.mock_calls[0][2]\n    data = json.loads(payload[\"data\"])\n    assert \"data\" in data\n    assert isinstance(data, dict)\n    assert \"key\" in data[\"data\"]\n    assert data[\"data\"][\"key\"] == \"value\"\n    assert \"key2\" in data[\"data\"]\n    assert data[\"data\"][\"key2\"] == \"value2\"\n\n    assert \"notification\" in data\n    assert isinstance(data[\"notification\"], dict)\n    assert \"notification\" in data[\"notification\"]\n    assert isinstance(data[\"notification\"][\"notification\"], dict)\n    assert \"image\" in data[\"notification\"][\"notification\"]\n    assert (\n        data[\"notification\"][\"notification\"][\"image\"]\n        == \"https://example.com/interesting.png\"\n    )\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_legacy_priorities(mock_post_legacy):\n    \"\"\"NotifyFCM() Legacy/APIKey priorities checks.\"\"\"\n\n    obj = Apprise.instantiate(\"fcm://abc123/device/?priority=low\")\n    assert mock_post_legacy.call_count == 0\n\n    # Send our notification\n    assert obj.notify(title=\"title\", body=\"body\") is True\n\n    # Test our call count\n    assert mock_post_legacy.call_count == 1\n    assert (\n        mock_post_legacy.call_args_list[0][0][0]\n        == \"https://fcm.googleapis.com/fcm/send\"\n    )\n\n    payload = mock_post_legacy.mock_calls[0][2]\n    data = json.loads(payload[\"data\"])\n    assert \"data\" not in data\n    assert \"notification\" in data\n    assert isinstance(data[\"notification\"], dict)\n    assert \"notification\" in data[\"notification\"]\n    assert isinstance(data[\"notification\"][\"notification\"], dict)\n    assert \"image\" not in data[\"notification\"][\"notification\"]\n    assert \"priority\" in data\n\n    # legacy can only switch between high/low\n    assert data[\"priority\"] == \"normal\"\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_legacy_no_colors(mock_post_legacy):\n    \"\"\"NotifyFCM() Legacy/APIKey `color=no` checks.\"\"\"\n\n    obj = Apprise.instantiate(\"fcm://abc123/device/?color=no\")\n    assert mock_post_legacy.call_count == 0\n\n    # Send our notification\n    assert obj.notify(title=\"title\", body=\"body\") is True\n\n    # Test our call count\n    assert mock_post_legacy.call_count == 1\n    assert (\n        mock_post_legacy.call_args_list[0][0][0]\n        == \"https://fcm.googleapis.com/fcm/send\"\n    )\n\n    payload = mock_post_legacy.mock_calls[0][2]\n    data = json.loads(payload[\"data\"])\n    assert \"data\" not in data\n    assert \"notification\" in data\n    assert isinstance(data[\"notification\"], dict)\n    assert \"notification\" in data[\"notification\"]\n    assert isinstance(data[\"notification\"][\"notification\"], dict)\n    assert \"image\" not in data[\"notification\"][\"notification\"]\n    assert \"color\" not in data[\"notification\"][\"notification\"]\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_legacy_colors(mock_post_legacy):\n    \"\"\"NotifyFCM() Legacy/APIKey colors checks.\"\"\"\n\n    obj = Apprise.instantiate(\"fcm://abc123/device/?color=AA001b\")\n    assert mock_post_legacy.call_count == 0\n\n    # Send our notification\n    assert obj.notify(title=\"title\", body=\"body\") is True\n\n    # Test our call count\n    assert mock_post_legacy.call_count == 1\n    assert (\n        mock_post_legacy.call_args_list[0][0][0]\n        == \"https://fcm.googleapis.com/fcm/send\"\n    )\n\n    payload = mock_post_legacy.mock_calls[0][2]\n    data = json.loads(payload[\"data\"])\n    assert \"data\" not in data\n    assert \"notification\" in data\n    assert isinstance(data[\"notification\"], dict)\n    assert \"notification\" in data[\"notification\"]\n    assert isinstance(data[\"notification\"][\"notification\"], dict)\n    assert \"image\" not in data[\"notification\"][\"notification\"]\n    assert \"color\" in data[\"notification\"][\"notification\"]\n    assert data[\"notification\"][\"notification\"][\"color\"] == \"#aa001b\"\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_oauth_default(mock_post):\n    \"\"\"\n    NotifyFCM() general OAuth checks - success.\n    Test using a valid Project ID and key file.\n    \"\"\"\n\n    obj = Apprise.instantiate(\n        f\"fcm://mock-project-id/device/#topic/?keyfile={FCM_KEYFILE}\"\n    )\n\n    # send our notification\n    assert obj.notify(\"test\") is True\n\n    # Test our call count\n    assert mock_post.call_count == 3\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send\"\n    )\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_oauth_invalid_project_id(mock_post):\n    \"\"\"NotifyFCM() OAuth checks, with invalid project id.\"\"\"\n\n    # Test having a valid keyfile, but not a valid project id match.\n    obj = Apprise.instantiate(\n        f\"fcm://invalid_project_id/device/?keyfile={FCM_KEYFILE}\"\n    )\n\n    # we'll fail as a result\n    assert obj.notify(\"test\") is False\n\n    # Test our call count\n    assert mock_post.call_count == 0\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_oauth_keyfile_error(mock_post):\n    \"\"\"NotifyFCM() OAuth checks, while unable to read key file.\"\"\"\n\n    # Now we test using a valid Project ID but we can't open our file\n    obj = Apprise.instantiate(\n        f\"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}\"\n    )\n\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        # we'll fail as a result\n        assert obj.notify(\"test\") is False\n\n    # Test our call count\n    assert mock_post.call_count == 0\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_oauth_data_parameters(mock_post):\n    \"\"\"NotifyFCM() OAuth checks, success.\n\n    Test using a valid Project ID and data parameters.\n    \"\"\"\n\n    obj = Apprise.instantiate(\n        f\"fcm://mock-project-id/device/#topic/?keyfile={FCM_KEYFILE}\"\n        \"&+key=value&+key2=value2\"\n        \"&image_url=https://example.com/interesting.png\"\n    )\n    assert mock_post.call_count == 0\n\n    # send our notification\n    assert obj.notify(\"test\") is True\n\n    # Test our call count\n    assert mock_post.call_count == 3\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send\"\n    )\n    payload = mock_post.mock_calls[1][2]\n    data = json.loads(payload[\"data\"])\n    assert \"message\" in data\n    assert isinstance(data[\"message\"], dict)\n    assert \"data\" in data[\"message\"]\n    assert isinstance(data[\"message\"][\"data\"], dict)\n    assert \"key\" in data[\"message\"][\"data\"]\n    assert data[\"message\"][\"data\"][\"key\"] == \"value\"\n    assert \"key2\" in data[\"message\"][\"data\"]\n    assert data[\"message\"][\"data\"][\"key2\"] == \"value2\"\n\n    assert \"notification\" in data[\"message\"]\n    assert isinstance(data[\"message\"][\"notification\"], dict)\n    assert \"image\" in data[\"message\"][\"notification\"]\n    assert (\n        data[\"message\"][\"notification\"][\"image\"]\n        == \"https://example.com/interesting.png\"\n    )\n\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send\"\n    )\n\n    payload = mock_post.mock_calls[2][2]\n    data = json.loads(payload[\"data\"])\n    assert \"message\" in data\n    assert isinstance(data[\"message\"], dict)\n    assert \"data\" in data[\"message\"]\n    assert isinstance(data[\"message\"][\"data\"], dict)\n    assert \"key\" in data[\"message\"][\"data\"]\n    assert data[\"message\"][\"data\"][\"key\"] == \"value\"\n    assert \"key2\" in data[\"message\"][\"data\"]\n    assert data[\"message\"][\"data\"][\"key2\"] == \"value2\"\n\n    assert \"notification\" in data[\"message\"]\n    assert isinstance(data[\"message\"][\"notification\"], dict)\n    assert \"image\" in data[\"message\"][\"notification\"]\n    assert (\n        data[\"message\"][\"notification\"][\"image\"]\n        == \"https://example.com/interesting.png\"\n    )\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_oauth_priorities(mock_post):\n    \"\"\"Verify priorities work as intended.\"\"\"\n\n    obj = Apprise.instantiate(\n        f\"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}&priority=high\"\n    )\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert obj.notify(title=\"title\", body=\"body\") is True\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send\"\n    )\n    payload = mock_post.mock_calls[1][2]\n    data = json.loads(payload[\"data\"])\n    assert \"message\" in data\n    assert isinstance(data[\"message\"], dict)\n    assert \"data\" not in data[\"message\"]\n    assert \"notification\" in data[\"message\"]\n    assert isinstance(data[\"message\"][\"notification\"], dict)\n    assert \"image\" not in data[\"message\"][\"notification\"]\n    assert data[\"message\"][\"apns\"][\"headers\"][\"apns-priority\"] == \"10\"\n    assert data[\"message\"][\"webpush\"][\"headers\"][\"Urgency\"] == \"high\"\n    assert data[\"message\"][\"android\"][\"priority\"] == \"HIGH\"\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_oauth_no_colors(mock_post):\n    \"\"\"Verify `color=no` work as intended.\"\"\"\n\n    obj = Apprise.instantiate(\n        f\"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}&color=no\"\n    )\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert obj.notify(title=\"title\", body=\"body\") is True\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send\"\n    )\n    payload = mock_post.mock_calls[1][2]\n    data = json.loads(payload[\"data\"])\n    assert \"message\" in data\n    assert isinstance(data[\"message\"], dict)\n    assert \"data\" not in data[\"message\"]\n    assert \"notification\" in data[\"message\"]\n    assert isinstance(data[\"message\"][\"notification\"], dict)\n    assert \"color\" not in data[\"message\"][\"notification\"]\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_oauth_colors(mock_post):\n    \"\"\"Verify colors work as intended.\"\"\"\n\n    obj = Apprise.instantiate(\n        f\"fcm://mock-project-id/device/?keyfile={FCM_KEYFILE}&color=#12AAbb\"\n    )\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert obj.notify(title=\"title\", body=\"body\") is True\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://fcm.googleapis.com/v1/projects/mock-project-id/messages:send\"\n    )\n    payload = mock_post.mock_calls[1][2]\n    data = json.loads(payload[\"data\"])\n    assert \"message\" in data\n    assert isinstance(data[\"message\"], dict)\n    assert \"data\" not in data[\"message\"]\n    assert \"notification\" in data[\"message\"]\n    assert isinstance(data[\"message\"][\"notification\"], dict)\n    assert \"color\" in data[\"message\"][\"android\"][\"notification\"]\n    assert data[\"message\"][\"android\"][\"notification\"][\"color\"] == \"#12aabb\"\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_keyfile_parse_default(mock_post):\n    \"\"\"NotifyFCM() KeyFile Tests.\"\"\"\n\n    oauth = GoogleOAuth()\n    # We can not get an Access Token without content loaded\n    assert oauth.access_token is None\n\n    # Load our content\n    assert oauth.load(FCM_KEYFILE) is True\n    assert oauth.access_token is not None\n\n    # Test our call count\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n\n    mock_post.reset_mock()\n\n    # a second call uses cache since our token hasn't expired yet\n    assert oauth.access_token is not None\n    assert mock_post.call_count == 0\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_keyfile_parse_no_expiry(mock_post):\n    \"\"\"Test case without `expires_in` entry.\"\"\"\n\n    mock_post.return_value.content = json.dumps({\n        \"access_token\": \"ya29.c.abcd\",\n        \"token_type\": \"Bearer\",\n    })\n\n    oauth = GoogleOAuth()\n    assert oauth.load(FCM_KEYFILE) is True\n    assert oauth.access_token is not None\n\n    # Test our call count\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_keyfile_parse_user_agent(mock_post):\n    \"\"\"Test case with `user-agent` override.\"\"\"\n\n    oauth = GoogleOAuth(user_agent=\"test-agent-override\")\n    assert oauth.load(FCM_KEYFILE) is True\n    assert oauth.access_token is not None\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://accounts.google.com/o/oauth2/token\"\n    )\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_keyfile_parse_keyfile_failures(mock_post: mock.Mock):\n    \"\"\"Test some errors that can get thrown when trying to handle the\n    `service_account.json` file.\"\"\"\n\n    # Now we test a case where we can't access the file we've been pointed to:\n    oauth = GoogleOAuth()\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        # We will fail to retrieve our Access Token\n        assert oauth.load(FCM_KEYFILE) is False\n        assert oauth.access_token is None\n\n    oauth = GoogleOAuth()\n    with mock.patch(\"json.loads\", side_effect=([],)):\n        # We will fail to retrieve our Access Token since we did not parse\n        # a dictionary\n        assert oauth.load(FCM_KEYFILE) is False\n        assert oauth.access_token is None\n\n    # Case where we can't load the PEM key:\n    oauth = GoogleOAuth()\n    with mock.patch(\n        \"cryptography.hazmat.primitives.serialization.load_pem_private_key\",\n        side_effect=ValueError(\"\"),\n    ):\n        assert oauth.load(FCM_KEYFILE) is False\n        assert oauth.access_token is None\n\n    # Case where we can't load the PEM key:\n    oauth = GoogleOAuth()\n    with mock.patch(\n        \"cryptography.hazmat.primitives.serialization.load_pem_private_key\",\n        side_effect=TypeError(\"\"),\n    ):\n        assert oauth.load(FCM_KEYFILE) is False\n        assert oauth.access_token is None\n\n    # Case where we can't load the PEM key:\n    oauth = GoogleOAuth()\n    with mock.patch(\n        \"cryptography.hazmat.primitives.serialization.load_pem_private_key\",\n        side_effect=UnsupportedAlgorithm(\"\"),\n    ):\n        # Note: This test should be te\n        assert oauth.load(FCM_KEYFILE) is False\n        assert oauth.access_token is None\n\n    # Verify that not a single call to the web escaped the test harness.\n    assert mock_post.mock_calls == []\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_keyfile_parse_token_failures(mock_post):\n    \"\"\"Test some web errors that can occur when speaking upstream with Google\n    to get our token generated.\"\"\"\n\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    mock_post.return_value.content = b\"\"\n\n    oauth = GoogleOAuth()\n    assert oauth.load(FCM_KEYFILE) is True\n\n    # We'll fail due to an bad web response\n    assert oauth.access_token is None\n\n    # Return our status code to how it was\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # No access token\n    bad_response_1 = mock.Mock()\n    bad_response_1.content = json.dumps({\n        \"expires_in\": 3599,\n        \"token_type\": \"Bearer\",\n    })\n\n    # Invalid JSON\n    bad_response_2 = mock.Mock()\n    bad_response_2.content = \"{\"\n\n    mock_post.return_value = None\n    # Throw an exception on the first call to requests.post()\n    for side_effect in (\n        requests.RequestException(),\n        [bad_response_1],\n        [bad_response_2],\n    ):\n        mock_post.side_effect = side_effect\n\n        # Test all of our bad side effects\n        oauth = GoogleOAuth()\n        assert oauth.load(FCM_KEYFILE) is True\n\n        # We'll fail due to an bad web response\n        assert oauth.access_token is None\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_bad_keyfile_parse():\n    \"\"\"NotifyFCM() KeyFile Bad Service Account Type Tests.\"\"\"\n\n    path = os.path.join(PRIVATE_KEYFILE_DIR, \"service_account-bad-type.json\")\n    oauth = GoogleOAuth()\n    assert oauth.load(path) is False\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_keyfile_missing_entries_parse(tmpdir):\n    \"\"\"NotifyFCM() KeyFile Missing Entries Test.\"\"\"\n\n    # Prepare a base keyfile reference to use\n    path = os.path.join(PRIVATE_KEYFILE_DIR, \"service_account.json\")\n    with open(path, encoding=\"utf-8\") as fp:\n        content = json.loads(fp.read())\n\n    path = tmpdir.join(\"fcm_keyfile.json\")\n\n    # Test that we fail to load if the following keys are missing:\n    for entry in (\n        \"client_email\",\n        \"private_key_id\",\n        \"private_key\",\n        \"type\",\n        \"project_id\",\n    ):\n\n        # Ensure the key actually exists in our file\n        assert entry in content\n\n        # Create a copy of our content\n        content_copy = content.copy()\n\n        # Remove our entry we expect to validate against\n        del content_copy[entry]\n        assert entry not in content_copy\n\n        path.write(json.dumps(content_copy))\n\n        oauth = GoogleOAuth()\n        assert oauth.load(str(path)) is False\n\n    # Now write ourselves a bad JSON file\n    path.write(\"{\")\n    oauth = GoogleOAuth()\n    assert oauth.load(str(path)) is False\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_priority_manager():\n    \"\"\"NotifyFCM() FCMPriorityManager() Testing.\"\"\"\n\n    for mode in FCM_MODES:\n        for priority in FCM_PRIORITIES:\n            instance = FCMPriorityManager(mode, priority)\n            assert isinstance(instance.payload(), dict)\n            # Verify it's not empty\n            assert bool(instance)\n            assert instance.payload()\n            assert str(instance) == priority\n\n    # We do not have to set a priority\n    instance = FCMPriorityManager(mode)\n    assert isinstance(instance.payload(), dict)\n\n    # Dictionary is empty though\n    assert not bool(instance)\n    assert not instance.payload()\n    assert str(instance) == \"\"\n\n    with pytest.raises(TypeError):\n        instance = FCMPriorityManager(mode, \"invalid\")\n\n    with pytest.raises(TypeError):\n        instance = FCMPriorityManager(\"invald\", \"high\")\n\n    # mode validation is done at the higher NotifyFCM() level so\n    # it is not tested here (not required)\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_fcm_color_manager():\n    \"\"\"NotifyFCM() FCMColorManager() Testing.\"\"\"\n\n    # No colors\n    instance = FCMColorManager(\"no\")\n    assert bool(instance) is False\n    assert instance.get() is None\n    # We'll return that we are not defined\n    assert str(instance) == \"no\"\n\n    # Asset colors\n    instance = FCMColorManager(\"yes\")\n    assert isinstance(instance.get(), str)\n    # Output: #rrggbb\n    assert len(instance.get()) == 7\n    # Starts with has symbol\n    assert instance.get()[0] == \"#\"\n    # We'll return that we are defined but using default configuration\n    assert str(instance) == \"yes\"\n\n    # We will be `true` because we can acquire a color based on what was\n    # passed in\n    assert bool(instance)\n\n    # Custom color\n    instance = FCMColorManager(\"#A2B3A4\")\n    assert isinstance(instance.get(), str)\n    assert instance.get() == \"#a2b3a4\"\n    assert bool(instance)\n    # str() response does not include hashtag\n    assert str(instance) == \"a2b3a4\"\n\n    # Custom color (no hashtag)\n    instance = FCMColorManager(\"A2B3A4\")\n    assert isinstance(instance.get(), str)\n    # Hashtag is always part of output\n    assert instance.get() == \"#a2b3a4\"\n    assert bool(instance)\n    # str() response does not include hashtag\n    assert str(instance) == \"a2b3a4\"\n\n    # Custom color (no hashtag) but only using 3 letter rgb values\n    instance = FCMColorManager(\"AC4\")\n    assert isinstance(instance.get(), str)\n    # Hashtag is always part of output\n    assert instance.get() == \"#aacc44\"\n    assert bool(instance)\n    # str() response does not include hashtag\n    assert str(instance) == \"aacc44\"\n\n\n@pytest.mark.skipif(\n    \"cryptography\" in sys.modules,\n    reason=\"Requires that cryptography NOT be installed\",\n)\ndef test_plugin_fcm_cryptography_import_error():\n    \"\"\"NotifyFCM Cryptography loading failure.\"\"\"\n\n    # Prepare a base keyfile reference to use\n    path = os.path.join(PRIVATE_KEYFILE_DIR, \"service_account.json\")\n\n    # Attempt to instantiate our object\n    obj = Apprise.instantiate(\n        f\"fcm://mock-project-id/device/#topic/?keyfile={path!s}\"\n    )\n\n    # It's not possible because our cryptography depedancy is missing\n    assert obj is None\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\n@mock.patch(\"requests.post\")\ndef test_plugin_fcm_edge_cases(mock_post):\n    \"\"\"NotifyFCM() Edge Cases.\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = b\"\"\n    mock_post.return_value = response\n\n    # this tests an edge case where verify if the data_kwargs is a dictionary\n    # or not.  Below, we don't even define it, so it will be None (causing\n    # the check to go).  We'll still correctly instantiate a plugin:\n    obj = NotifyFCM(\"project\", \"api:123\", targets=\"device\")\n    assert obj is not None\n"
  },
  {
    "path": "tests/test_plugin_feishu.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.feishu import NotifyFeishu\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"feishu://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"feishu://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"feishu://%badtoken%\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"feishu://abc123\",\n        {\n            # Test token\n            \"instance\": NotifyFeishu,\n        },\n    ),\n    (\n        \"feishu://?token=abc123\",\n        {\n            # Test token\n            \"instance\": NotifyFeishu,\n        },\n    ),\n    (\n        \"feishu://token\",\n        {\n            \"instance\": NotifyFeishu,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"feishu://token\",\n        {\n            \"instance\": NotifyFeishu,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"feishu://token\",\n        {\n            \"instance\": NotifyFeishu,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_feishu_urls():\n    \"\"\"NotifyFeishu() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_flock.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.flock import NotifyFlock\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"flock://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # An invalid url\n    (\n        \"flock://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Provide a token\n    (\n        \"flock://%s\" % (\"t\" * 24),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Image handling\n    (\n        \"flock://%s?image=True\" % (\"t\" * 24),\n        {\n            \"instance\": NotifyFlock,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"flock://t...t\",\n        },\n    ),\n    (\n        \"flock://%s?image=False\" % (\"t\" * 24),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    (\n        \"flock://%s?image=True\" % (\"t\" * 24),\n        {\n            \"instance\": NotifyFlock,\n            # Run test when image is set to True, but one couldn't actually be\n            # loaded from the Asset Object.\n            \"include_image\": False,\n        },\n    ),\n    # Test to=\n    (\n        \"flock://{}?to=u:{}&format=markdown\".format(\"i\" * 24, \"u\" * 12),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Provide markdown format\n    (\n        \"flock://%s?format=markdown\" % (\"i\" * 24),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Provide text format\n    (\n        \"flock://%s?format=text\" % (\"i\" * 24),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Native URL Support, take the slack URL and still build from it\n    (\n        \"https://api.flock.com/hooks/sendMessage/{}/\".format(\"i\" * 24),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Native URL Support with arguments\n    (\n        \"https://api.flock.com/hooks/sendMessage/{}/?format=markdown\".format(\n            \"i\" * 24\n        ),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Bot API presumed if one or more targets are specified\n    # Provide markdown format\n    (\n        \"flock://{}/u:{}?format=markdown\".format(\"i\" * 24, \"u\" * 12),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Bot API presumed if one or more targets are specified\n    # Provide text format\n    (\n        \"flock://{}/u:{}?format=html\".format(\"i\" * 24, \"u\" * 12),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Bot API presumed if one or more targets are specified\n    # u: is optional\n    (\n        \"flock://{}/{}?format=text\".format(\"i\" * 24, \"u\" * 12),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Bot API presumed if one or more targets are specified\n    # Multi-entries\n    (\n        \"flock://{}/g:{}/u:{}?format=text\".format(\n            \"i\" * 24, \"g\" * 12, \"u\" * 12\n        ),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Bot API presumed if one or more targets are specified\n    # Multi-entries using @ for user and # for channel\n    (\n        \"flock://{}/#{}/@{}?format=text\".format(\"i\" * 24, \"g\" * 12, \"u\" * 12),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Bot API presumed if one or more targets are specified\n    # has bad entry\n    (\n        \"flock://{}/g:{}/u:{}?format=text\".format(\n            \"i\" * 24, \"g\" * 12, \"u\" * 10\n        ),\n        {\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Invalid user/group defined\n    (\n        \"flock://%s/g:/u:?format=text\" % (\"i\" * 24),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # we don't focus on the invalid length of the user/group fields.\n    # As a result, the following will load and pass the data upstream\n    (\n        \"flock://{}/g:{}/u:{}?format=text\".format(\n            \"i\" * 24, \"g\" * 14, \"u\" * 10\n        ),\n        {\n            # We will still instantiate the object\n            \"instance\": NotifyFlock,\n        },\n    ),\n    # Error Testing\n    (\n        \"flock://{}/g:{}/u:{}?format=text\".format(\n            \"i\" * 24, \"g\" * 12, \"u\" * 10\n        ),\n        {\n            \"instance\": NotifyFlock,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"flock://%s/\" % (\"t\" * 24),\n        {\n            \"instance\": NotifyFlock,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"flock://%s/\" % (\"t\" * 24),\n        {\n            \"instance\": NotifyFlock,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"flock://%s/\" % (\"t\" * 24),\n        {\n            \"instance\": NotifyFlock,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_flock_urls():\n    \"\"\"NotifyFlock() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_flock_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyFlock() Edge Cases.\"\"\"\n\n    # Initializes the plugin with an invalid token\n    with pytest.raises(TypeError):\n        NotifyFlock(token=None)\n    # Whitespace also acts as an invalid token value\n    with pytest.raises(TypeError):\n        NotifyFlock(token=\"   \")\n"
  },
  {
    "path": "tests/test_plugin_fluxer.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta, timezone\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom random import choice\nfrom string import ascii_uppercase as str_alpha, digits as str_num\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyFormat, NotifyType\nfrom apprise.common import OverflowMode\nfrom apprise.plugins.fluxer import NotifyFluxer\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n\ndef _tokens() -> tuple[str, str]:\n    \"\"\"Return tokens that satisfy Fluxer validation.\"\"\"\n\n    # webhook_id: digits, >= 10\n    # webhook_token: [A-Za-z0-9_-], >= 16\n    return (\"0\" * 10, \"B\" * 40)\n\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"fluxer://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # An invalid url\n    (\n        \"fluxer://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No webhook_token specified\n    (\n        \"fluxer://%s\" % (\"0\" * 10),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Provide both a webhook id and a webhook token\n    (\n        \"fluxer://{}/{}\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Provide a temporary username\n    (\n        \"fluxer://l2g@{}/{}\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Mode set to private, but no hostname was fluxers; we toggle to cloud mode\n    (\n        \"fluxer://api.fluxer.app/{}/{}?mode=private\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # test image= field\n    (\n        \"fluxer://{}/{}?format=markdown&footer=Yes&image=Yes&ping=Joe\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"fluxer://{}/{}?format=markdown&footer=Yes&image=No&fields=no\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"fluxer://jack@{}/{}?format=markdown&footer=Yes&image=Yes\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"fluxer://jack@{}/{}?mode=private&host=example.ca\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"fluxer://{}/{}?mode=private&host=example.ca&name=jack\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"fluxer://example.ca:123/{}/{}\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        # Invalid Mode\n        \"fluxer://jack@{}/{}?mode=invalid\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"https://api.fluxer.app/webhooks/{}/{}\".format(*_tokens()),\n        {\n            # Native URL Support\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"https://api.fluxer.app/v1/webhooks/{}/{}?footer=yes\".format(*_tokens()),\n        {\n            # Native URL Support with arguments\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n            \"privacy_url\": \"fluxer://0...0/B...B/\",\n        },\n    ),\n    (\n        \"https://api.fluxer.app/v1/webhooks/{}/{}?footer=yes&botname=joe\".format(\n            *_tokens()\n        ),\n        {\n            # Native URL Support with arguments\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n            \"privacy_url\": \"fluxer://joe@0...0/B...B/\",\n        },\n    ),\n    (\n        \"fluxer://{}/{}?format=markdown&avatar=No&footer=No\".format(\n            *_tokens()\n        ),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"fluxer://{}/{}?flags=1\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"fluxer://{}/{}?flags=-1\".format(*_tokens()),\n        {\n            # invalid flags specified (variation 1)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"fluxer://{}/{}?flags=invalid\".format(*_tokens()),\n        {\n            # invalid flags specified (variation 2)\n            \"instance\": TypeError,\n        },\n    ),\n    # different format support\n    (\n        \"fluxer://{}/{}?format=markdown\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Thread ID (forces markdown mode)\n    (\n        \"fluxer://{}/{}?format=markdown&thread=abc123\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"fluxer://{}/{}?format=text\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test with href (title link)\n    (\n        \"fluxer://{}/{}?hmarkdown=true&ref=http://localhost\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test with url (title link) - Alias of href\n    (\n        \"fluxer://{}/{}?markdown=true&url=http://localhost\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test with avatar URL\n    (\n        \"fluxer://{}/{}?avatar_url=http://localhost/test.jpg\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test without image set\n    (\n        \"fluxer://{}/{}\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            \"requests_response_code\": requests.codes.no_content,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"fluxer://{}/{}\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"fluxer://{}/{}\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"fluxer://{}/{}\".format(*_tokens()),\n        {\n            \"instance\": NotifyFluxer,\n            # Throws a series of i/o exceptions with this flag set and tests\n            # that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_fluxer_urls() -> None:\n    \"\"\"NotifyFluxer() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_notifications(mock_post: mock.MagicMock) -> None:\n    \"\"\"NotifyFluxer() Notifications/Ping Support.\"\"\"\n\n    webhook_id, webhook_token = _tokens()\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = b\"\"\n    mock_post.return_value.headers = {}\n\n    # Test our header parsing when not lead with a header\n    body = \"\"\"\n    # Heading\n    @everyone and @admin, wake and meet our new user <@123>; <@&456>\"\n    \"\"\"\n\n    results = NotifyFluxer.parse_url(\n        f\"fluxer://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"webhook_id\"] == webhook_id\n    assert results[\"webhook_token\"] == webhook_token\n    assert results[\"schema\"] == \"fluxer\"\n\n    instance = NotifyFluxer(**results)\n    assert isinstance(instance, NotifyFluxer)\n\n    assert instance.send(body=body) is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert (\n        details[0][0]\n        == f\"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    payload = loads(details[1][\"data\"])\n    assert isinstance(payload, dict)\n\n    assert \"allow_mentions\" in payload\n    assert set(payload[\"allow_mentions\"][\"users\"]) == {\"123\"}\n    assert set(payload[\"allow_mentions\"][\"roles\"]) == {\"456\"}\n    assert set(payload[\"allow_mentions\"][\"parse\"]) == {\"everyone\", \"admin\"}\n    assert payload[\"content\"].startswith(\"👉 \")\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    results = NotifyFluxer.parse_url(\n        f\"fluxer://{webhook_id}/{webhook_token}/?format=text\"\n    )\n\n    instance = NotifyFluxer(**results)\n    assert isinstance(instance, NotifyFluxer)\n\n    assert instance.send(body=body) is True\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # text mode does not parse mentions from body\n    assert \"allow_mentions\" not in payload\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    body = \" \"\n    results = NotifyFluxer.parse_url(\n        # & -> %26 for role otherwise & separates our URL from further parsing\n        f\"fluxer://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>\"\n    )\n    instance = NotifyFluxer(**results)\n    assert isinstance(instance, NotifyFluxer)\n\n    assert instance.send(body=body) is True\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert \"allow_mentions\" in payload\n    assert set(payload[\"allow_mentions\"][\"users\"]) == {\"321\"}\n    assert set(payload[\"allow_mentions\"][\"roles\"]) == {\"654\"}\n    assert set(payload[\"allow_mentions\"][\"parse\"]) == {\"joe\"}\n    assert \"<@321>\" in payload[\"content\"]\n    assert \"<@&654>\" in payload[\"content\"]\n    assert \"@joe\" in payload[\"content\"]\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Test our body in text mode, with ping=set\n    body = \"\"\"\n    # Heading\n    @everyone and @admin, wake and meet our new user <@123>; <@&456>\"\n    \"\"\"\n\n    results = NotifyFluxer.parse_url(\n        # & -> %26 for role otherwise & separates our URL from further parsing\n        f\"fluxer://{webhook_id}/{webhook_token}/?ping=@joe,<@321>,<@%26654>\"\n        \"&format=text\"\n    )\n\n    instance = NotifyFluxer(**results)\n    assert isinstance(instance, NotifyFluxer)\n\n    assert instance.send(body=body) is True\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # Payload only includes elements on ping= line with text mode\n    assert \"allow_mentions\" in payload\n    assert set(payload[\"allow_mentions\"][\"users\"]) == {\"321\"}\n    assert set(payload[\"allow_mentions\"][\"roles\"]) == {\"654\"}\n    assert set(payload[\"allow_mentions\"][\"parse\"]) == {\"joe\"}\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"time.sleep\")\ndef test_plugin_fluxer_429(\n    mock_sleep: mock.MagicMock,\n    mock_post: mock.MagicMock,\n) -> None:\n    \"\"\"\n    NotifyFluxer() 429 handling\n\n    Focus: Fluxer-specific HTTP 429 + Retry-After handling (recursive retry),\n    including header parsing edge cases and retry exhaustion.\n    \"\"\"\n\n    # Prevent throttling side effects\n    mock_sleep.return_value = True\n\n    webhook_id, webhook_token = _tokens()\n\n    # Basic construction checks (keep these, they match plugin validation)\n    with pytest.raises(TypeError):\n        NotifyFluxer(webhook_id=None, webhook_token=webhook_token)\n    with pytest.raises(TypeError):\n        NotifyFluxer(webhook_id=\"  \", webhook_token=webhook_token)\n\n    with pytest.raises(TypeError):\n        NotifyFluxer(webhook_id=webhook_id, webhook_token=None)\n    with pytest.raises(TypeError):\n        NotifyFluxer(webhook_id=webhook_id, webhook_token=\"   \")\n\n    obj = NotifyFluxer(\n        webhook_id=webhook_id,\n        webhook_token=webhook_token,\n        footer=True,\n        include_image=True,\n    )\n\n    # url() must always return a string\n    assert isinstance(obj.url(), str)\n\n    # Helper to build responses\n    def _resp(code: int, headers: dict[str, str] | None = None) -> mock.Mock:\n        r = mock.Mock()\n        r.status_code = code\n        r.content = b\"\"\n        r.headers = headers or {}\n        return r\n\n    # Retry-After header missing -> defaults to default_delay_sec (1.0)\n    mock_post.reset_mock()\n    mock_sleep.reset_mock()\n    mock_post.side_effect = [\n        _resp(requests.codes.too_many_requests, {}),\n        _resp(requests.codes.no_content, {}),\n    ]\n\n    assert obj.send(body=\"test\") is True\n    assert mock_post.call_count == 2\n    assert mock_sleep.call_count == 1\n    assert mock_sleep.call_args_list[-1][0][0] == \\\n        pytest.approx(1.0, rel=0, abs=0.05)\n\n    # Retry-After invalid -> falls back to 1.0\n    mock_post.reset_mock()\n    mock_sleep.reset_mock()\n    mock_post.side_effect = [\n        _resp(requests.codes.too_many_requests, {\"Retry-After\": \"garbage\"}),\n        _resp(requests.codes.no_content, {}),\n    ]\n\n    assert obj.send(body=\"test\") is True\n    assert mock_post.call_count == 2\n    assert mock_sleep.call_count >= 1\n    assert mock_sleep.call_args_list[-1][0][0] == \\\n        pytest.approx(1.0, rel=0, abs=0.05)\n\n    # Retry-After < 1.0 -> max(1.0, value) enforces 1.0\n    mock_post.reset_mock()\n    mock_sleep.reset_mock()\n    mock_post.side_effect = [\n        _resp(requests.codes.too_many_requests, {\"Retry-After\": \"0.25\"}),\n        _resp(requests.codes.no_content, {}),\n    ]\n\n    assert obj.send(body=\"test\") is True\n    assert mock_post.call_count == 2\n    assert mock_sleep.call_count >= 1\n    assert mock_sleep.call_args_list[-1][0][0] == \\\n        pytest.approx(1.0, rel=0, abs=0.05)\n\n    # Retry-After valid integer -> sleeps that many seconds\n    mock_post.reset_mock()\n    mock_sleep.reset_mock()\n    mock_post.side_effect = [\n        _resp(requests.codes.too_many_requests, {\"Retry-After\": \"2\"}),\n        _resp(requests.codes.no_content, {}),\n    ]\n\n    assert obj.send(body=\"test\") is True\n    assert mock_post.call_count == 2\n    assert mock_sleep.call_count >= 1\n    assert mock_sleep.call_args_list[-1][0][0] == \\\n        pytest.approx(2.0, rel=0, abs=0.05)\n\n    # Retry exhaustion: default send() retries once.\n    # If we get 429 twice, second one is not retried and send fails.\n    mock_post.reset_mock()\n    mock_sleep.reset_mock()\n    mock_post.side_effect = [\n        _resp(requests.codes.too_many_requests, {\"Retry-After\": \"1\"}),\n        _resp(requests.codes.too_many_requests, {\"Retry-After\": \"1\"}),\n    ]\n\n    assert obj.send(body=\"test\") is False\n    assert mock_post.call_count == 2\n    assert mock_sleep.call_count >= 1\n\n    mock_sleep.reset_mock()\n    mock_post.reset_mock()\n\n    response = mock.Mock()\n    response.status_code = requests.codes.no_content\n    response.content = b\"\"\n    response.headers = {}\n    mock_post.return_value = response\n    mock_post.side_effect = None\n\n    # Force the 'now <= ratelimit_reset' path to compute a wait.\n    obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token)\n\n    # Force the rate-limit gate to run\n    obj.ratelimit_remaining = 0.0\n\n    now = datetime.now(timezone.utc).replace(tzinfo=None)\n    obj.ratelimit_reset = now - timedelta(seconds=2)\n\n    with mock.patch.object(obj, \"throttle\") as m_throttle:\n        assert obj.send(body=\"test\") is True\n\n    # We expect throttle(wait=~2.0) to be called.\n    assert m_throttle.call_count >= 1\n    wait = m_throttle.call_args_list[-1][1].get(\"wait\")\n\n    assert wait is None\n\n    # Call count\n    assert mock_post.call_count == 1\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Force the 'now < ratelimit_reset' path to compute a wait.\n    obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token)\n\n    # Force the rate-limit gate to run\n    obj.ratelimit_remaining = 0.0\n\n    now = datetime.now(timezone.utc).replace(tzinfo=None)\n    obj.ratelimit_reset = now + timedelta(seconds=2)\n\n    with mock.patch.object(obj, \"throttle\") as m_throttle:\n        assert obj.send(body=\"test\") is True\n\n    # We expect throttle(wait=~2.0) to be called.\n    assert m_throttle.call_count >= 1\n    wait = m_throttle.call_args_list[-1][1].get(\"wait\")\n\n    assert wait is not None\n    assert wait == pytest.approx(2.0, rel=0, abs=0.10)\n\n    # Call count\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_general(\n    mock_post: mock.MagicMock,\n) -> None:\n    \"\"\"NotifyFluxer() General Checks.\n    \"\"\"\n\n    webhook_id, webhook_token = _tokens()\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = \"\"\n\n    # Invalid webhook id\n    with pytest.raises(TypeError):\n        NotifyFluxer(webhook_id=None, webhook_token=webhook_token)\n    # Invalid webhook id (whitespace)\n    with pytest.raises(TypeError):\n        NotifyFluxer(webhook_id=\"  \", webhook_token=webhook_token)\n\n    # Invalid webhook token\n    with pytest.raises(TypeError):\n        NotifyFluxer(webhook_id=webhook_id, webhook_token=None)\n\n    # Private mode but no hostname provided\n    with pytest.raises(TypeError):\n        NotifyFluxer(\n            webhook_id=webhook_id,\n            webhook_token=webhook_token,\n            mode=\"private\",\n        )\n\n    obj = NotifyFluxer(\n        webhook_id=webhook_id,\n        webhook_token=webhook_token,\n        footer=True,\n        include_image=True,\n    )\n\n    # Simple Markdown Single line of text\n    test_markdown = \"body\"\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    assert isinstance(results, list) is True\n    assert len(results) == 0\n    assert desc == \"body\"\n\n    # Test our header parsing when not lead with a header\n    test_markdown = \"\"\"\n    A section of text that has no header at the top.\n    It also has a hash tag # <- in the middle of a\n    string.\n\n    ## Heading 1\n    body\n\n    # Heading 2\n\n    more content\n    on multi-lines\n    \"\"\"\n\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    # we have a description\n    assert isinstance(desc, str) is True\n    assert desc.startswith(\"A section of text that has no header at the top.\")\n    assert desc.endswith(\"string.\")\n\n    assert isinstance(results, list) is True\n    assert len(results) == 2\n    assert results[0][\"name\"] == \"Heading 1\"\n    assert results[0][\"value\"] == \"```md\\nbody\\n```\"\n    assert results[1][\"name\"] == \"Heading 2\"\n    assert (\n        results[1][\"value\"] == \"```md\\nmore content\\n    on multi-lines\\n```\"\n    )\n\n    # Test our header parsing\n    test_markdown = (\n        \"## Heading one\\nbody body\\n\\n\"\n        + \"# Heading 2 ##\\n\\nTest\\n\\n\"\n        + \"more content\\n\"\n        + \"even more content  \\t\\r\\n\\n\\n\"\n        + \"# Heading 3 ##\\n\\n\\n\"\n        + \"normal content\\n\"\n        + \"# heading 4\\n\"\n        + \"#### Heading 5\"\n    )\n\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    assert isinstance(results, list) is True\n    # No desc details filled out\n    assert isinstance(desc, str) is True\n    assert not desc\n\n    # We should have 5 sections (since there are 5 headers identified above)\n    assert len(results) == 5\n    assert results[0][\"name\"] == \"Heading one\"\n    assert results[0][\"value\"] == \"```md\\nbody body\\n```\"\n    assert results[1][\"name\"] == \"Heading 2\"\n    assert (\n        results[1][\"value\"]\n        == \"```md\\nTest\\n\\nmore content\\neven more content\\n```\"\n    )\n    assert results[2][\"name\"] == \"Heading 3\"\n    assert results[2][\"value\"] == \"```md\\nnormal content\\n```\"\n    assert results[3][\"name\"] == \"heading 4\"\n    assert results[3][\"value\"] == \"```\\n```\"\n    assert results[4][\"name\"] == \"Heading 5\"\n    assert results[4][\"value\"] == \"```\\n```\"\n\n    # Create an apprise instance\n    a = Apprise()\n\n    # Our processing is slightly different when we aren't using markdown\n    # as we do not pre-parse content during our notifications\n    assert (\n        a.add(\n            f\"fluxer://{webhook_id}/{webhook_token}/\"\n            \"?format=markdown&footer=Yes\"\n        )\n        is True\n    )\n\n    # This call includes an image with its payload:\n    orig_fluxer_max_fields = NotifyFluxer.fluxer_max_fields\n    try:\n        NotifyFluxer.fluxer_max_fields = 1\n        assert (\n            a.notify(\n                body=test_markdown,\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                body_format=NotifyFormat.TEXT,\n            )\n            is True\n        )\n    finally:\n        # Restore the original value to avoid impacting other tests\n        NotifyFluxer.fluxer_max_fields = orig_fluxer_max_fields\n\n    # Throw an exception on the forth call to requests.post()\n    # This allows to test our batch field processing\n    response = mock.Mock()\n    response.content = b\"\"\n    response.status_code = requests.codes.ok\n    response.headers = {}\n    mock_post.return_value = response\n    mock_post.side_effect = [\n        response,\n        response,\n        response,\n        requests.RequestException(),\n    ]\n\n    # Test our markdown\n    obj = Apprise.instantiate(\n        f\"fluxer://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n    assert isinstance(obj, NotifyFluxer)\n\n    # Force batching so we actually hit the 4th post() call\n    orig_fluxer_max_fields = NotifyFluxer.fluxer_max_fields\n    try:\n        NotifyFluxer.fluxer_max_fields = 1\n\n        assert (\n            obj.notify(\n                body=test_markdown, title=\"title\", notify_type=NotifyType.INFO\n            )\n            is False\n        )\n    finally:\n        NotifyFluxer.fluxer_max_fields = orig_fluxer_max_fields\n        mock_post.side_effect = None\n\n    # Empty String\n    desc, results = obj.extract_markdown_sections(\"\")\n    assert isinstance(results, list) is True\n    assert len(results) == 0\n\n    # No desc details filled out\n    assert isinstance(desc, str) is True\n    assert not desc\n\n    # String without Heading\n    test_markdown = (\n        \"Just a string without any header entries.\\n\" + \"A second line\"\n    )\n    desc, results = obj.extract_markdown_sections(test_markdown)\n    assert isinstance(results, list) is True\n    assert len(results) == 0\n\n    # No desc details filled out\n    assert isinstance(desc, str) is True\n    assert (\n        desc == \"Just a string without any header entries.\\n\" + \"A second line\"\n    )\n\n    # Use our test markdown string during a notification\n    assert (\n        obj.notify(\n            body=test_markdown, title=\"title\", notify_type=NotifyType.INFO\n        )\n        is True\n    )\n\n    # Create an apprise instance\n    a = Apprise()\n\n    # Our processing is slightly different when we aren't using markdown\n    # as we do not pre-parse content during our notifications\n    assert (\n        a.add(\n            f\"fluxer://{webhook_id}/{webhook_token}/\"\n            \"?format=markdown&footer=Yes\"\n        )\n        is True\n    )\n\n    # This call includes an image with it's payload:\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.TEXT,\n        )\n        is True\n    )\n\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.MARKDOWN,\n        )\n        is True\n    )\n\n    # Toggle our logo availability\n    a.asset.image_url_logo = None\n    assert (\n        a.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Create an apprise instance\n    a = Apprise()\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Test our threading\n    assert (\n        a.add(f\"fluxer://{webhook_id}/{webhook_token}/?thread=12345\") is True\n    )\n\n    # This call includes an image with it's payload:\n    assert a.notify(body=\"test\", title=\"title\") is True\n\n    assert mock_post.call_count == 1\n    response = mock_post.call_args_list[0][1]\n    assert \"params\" in response\n    assert response[\"params\"].get(\"thread_id\") == \"12345\"\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_overflow(mock_post):\n    \"\"\"NotifyFluxer() Overflow Checks.\"\"\"\n\n    webhook_id, webhook_token = _tokens()\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Some variables we use to control the data we work with\n    body_len = 8000\n    title_len = 1024\n\n    # Number of characters per line\n    row = 24\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num + \" \") for _ in range(body_len))\n    body = \"\\r\\n\".join(\n        [body[i:i + row] for i in range(0, len(body), row)]\n    )\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    results = NotifyFluxer.parse_url(\n        f\"fluxer://{webhook_id}/{webhook_token}/?overflow=split\"\n    )\n\n    instance = NotifyFluxer(**results)\n    assert isinstance(instance, NotifyFluxer)\n\n    results = instance._apply_overflow(\n        body, title=title, overflow=OverflowMode.SPLIT\n    )\n\n    # Ensure we never exceed 2000 characters\n    for entry in results:\n        assert len(entry[\"title\"]) <= instance.title_maxlen\n        assert len(entry[\"title\"]) + len(entry[\"body\"]) <= instance.body_maxlen\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_markdown_extra(mock_post):\n    \"\"\"NotifyFluxer() Markdown Extra Checks.\"\"\"\n\n    webhook_id, webhook_token = _tokens()\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = b\"\"\n    mock_post.return_value.headers = {}\n\n    # Reset our apprise object\n    a = Apprise()\n\n    assert (\n        a.add(\n            f\"fluxer://{webhook_id}/{webhook_token}/\"\n            \"?format=markdown&footer=Yes\"\n        )\n        is True\n    )\n\n    test_markdown = \"[green-blue](https://google.com)\"\n\n    # This call includes an image with it's payload:\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.TEXT,\n        )\n        is True\n    )\n\n    assert (\n        a.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_markdown_attachments(\n    mock_post: mock.MagicMock,\n) -> None:\n    # Prepare our tokens\n    webhook_id, webhook_token = _tokens()\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = b\"\"\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.internal_server_error\n    bad_response.content = b\"\"\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n\n    # Test our markdown\n    obj = Apprise.instantiate(\n        f\"fluxer://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n\n    # attach our content\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # Test notifications with mentions and attachments in it\n    assert (\n        obj.notify(\n            body=\"Say hello to <@1234>!\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://api.fluxer.app/webhooks/{webhook_id}/{webhook_token}\"\n    )\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # update our attachment to be valid\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    mock_post.return_value = None\n    # Throw an exception on the first call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.side_effect = [side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Throw an exception on the second call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.side_effect = [response, side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # handle a bad response\n    bad_response = mock.Mock()\n    bad_response.content = b\"\"\n    bad_response.headers = {}\n    bad_response.status_code = requests.codes.internal_server_error\n    mock_post.side_effect = [response, bad_response]\n\n    # We'll fail now because of an internal exception\n    assert obj.send(body=\"test\", attach=attach) is False\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_markdown_fields_batches_exactly(mock_post):\n    webhook_id, webhook_token = _tokens()\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = \"\"\n    response.headers = {}\n    mock_post.return_value = response\n\n    # Force tiny batches\n    NotifyFluxer.fluxer_max_fields = 1\n\n    body = \"# H1\\nv1\\n# H2\\nv2\\n# H3\\nv3\\n\"\n    obj = Apprise.instantiate(\n        f\"fluxer://{webhook_id}/{webhook_token}/?format=markdown&fields=yes\"\n    )\n    assert isinstance(obj, NotifyFluxer)\n\n    assert obj.send(body=body) is True\n\n    # H1, H2, H3 => 3 fields => 3 posts (since max_fields=1)\n    assert mock_post.call_count == 3\n\n    NotifyFluxer.fluxer_max_fields = 10\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_markdown_ping_is_additive(\n    mock_post: mock.MagicMock,\n) -> None:\n    webhook_id, webhook_token = _tokens()\n\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = b\"\"\n    mock_post.return_value.headers = {}\n\n    body = \"Body pings <@111> and <@&222> @everyone\"\n    results = NotifyFluxer.parse_url(\n        f\"fluxer://{webhook_id}/{webhook_token}/\"\n        \"?format=markdown\"\n        \"&ping=<@333>,<@%26444>,@joe\"\n    )\n    obj = NotifyFluxer(**results)\n\n    assert obj.send(body=body) is True\n    assert mock_post.call_count == 1\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert \"allow_mentions\" in payload\n    # union\n    assert set(payload[\"allow_mentions\"][\"users\"]) == {\"111\", \"333\"}\n    assert set(payload[\"allow_mentions\"][\"roles\"]) == {\"222\", \"444\"}\n    assert set(payload[\"allow_mentions\"][\"parse\"]) == {\"everyone\", \"joe\"}\n    assert payload[\"content\"].startswith(\"👉 \")\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_html_ping_is_exclusive(\n    mock_post: mock.MagicMock,\n) -> None:\n    webhook_id, webhook_token = _tokens()\n\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    body = \"Body includes <@111> <@&222> @everyone but must be ignored\"\n    results = NotifyFluxer.parse_url(\n        f\"fluxer://{webhook_id}/{webhook_token}/\"\n        \"?format=html\"\n        \"&ping=<@333>,<@%26444>,@joe\"\n    )\n    obj = NotifyFluxer(**results)\n\n    assert obj.send(body=body) is True\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert set(payload[\"allow_mentions\"][\"users\"]) == {\"333\"}\n    assert set(payload[\"allow_mentions\"][\"roles\"]) == {\"444\"}\n    assert set(payload[\"allow_mentions\"][\"parse\"]) == {\"joe\"}\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_markdown_no_mentions_has_no_allow_mentions(\n    mock_post: mock.MagicMock,\n) -> None:\n    webhook_id, webhook_token = _tokens()\n\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = b\"\"\n    mock_post.return_value.headers = {}\n\n    results = NotifyFluxer.parse_url(\n        f\"fluxer://{webhook_id}/{webhook_token}/?format=markdown\"\n    )\n    obj = NotifyFluxer(**results)\n\n    assert obj.send(body=\"Hello world\") is True\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert \"allow_mentions\" not in payload\n    assert \"content\" not in payload\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_markdown_single_field_posts_once(\n    mock_post: mock.MagicMock,\n) -> None:\n    webhook_id, webhook_token = _tokens()\n\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = b\"\"\n    response.headers = {}\n    mock_post.return_value = response\n\n    NotifyFluxer.fluxer_max_fields = 10\n\n    body = \"# H1\\nv1\\n\"\n    obj = Apprise.instantiate(\n        f\"fluxer://{webhook_id}/{webhook_token}/?format=markdown&fields=yes\"\n    )\n    assert isinstance(obj, NotifyFluxer)\n\n    assert obj.send(body=body) is True\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_fluxer_threading(mock_post: mock.MagicMock) -> None:\n    \"\"\"Threading passes thread_id parameter.\"\"\"\n\n    webhook_id, webhook_token = _tokens()\n\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = b\"\"\n    response.headers = {}\n    mock_post.return_value = response\n\n    a = Apprise()\n    assert (\n        a.add(f\"fluxer://{webhook_id}/{webhook_token}/\"\n              \"?thread=12345&thread_name=abc\") is True\n    )\n\n    assert a.notify(body=\"test\", title=\"title\") is True\n\n    kwargs = mock_post.call_args_list[0][1]\n    assert \"params\" in kwargs\n    assert kwargs[\"params\"].get(\"thread_id\") == \"12345\"\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload.get(\"thread_name\") == \"abc\"\n\n    assert \"thread_name=abc\" in a[0].url()\n    assert \"thread=12345\" in a[0].url()\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"time.sleep\")\n@mock.patch(\"builtins.open\")\ndef test_plugin_fluxer_429_attachment_closes_edge_cases(\n    mock_open: mock.MagicMock,\n    mock_sleep: mock.MagicMock,\n    mock_post: mock.MagicMock,\n) -> None:\n    \"\"\"\n    Cover 429 error during attachment upload triggers the pre-recursion close\n    loop, and close() exceptions are suppressed.\n    \"\"\"\n\n    mock_sleep.return_value = True\n\n    webhook_id, webhook_token = _tokens()\n    obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token)\n\n    attach_path = os.path.join(TEST_VAR_DIR, \"apprise-test.png\")\n    attach = AppriseAttachment(attach_path)\n\n    class _BadCloseIO:\n        def __init__(self) -> None:\n            self.closed = False\n\n        def close(self) -> None:\n            self.closed = True\n            raise OSError(\"boom\")\n\n    bad = _BadCloseIO()\n    mock_open.return_value = bad\n\n    def _resp(code: int, headers: dict[str, str] | None = None) -> mock.Mock:\n        r = mock.Mock()\n        r.status_code = code\n        r.content = b\"\"\n        r.headers = headers or {}\n        return r\n\n    # 3 posts:\n    #  (1) initial message post succeeds\n    #  (2) attachment post 429 triggers file close-before-recursion\n    #  (3) recursive retry succeeds (note: retry is non-attachment in baseline)\n    mock_post.side_effect = [\n        _resp(requests.codes.no_content, {}),\n        _resp(requests.codes.too_many_requests, {\"Retry-After\": \"1\"}),\n        _resp(requests.codes.no_content, {}),\n    ]\n\n    assert obj.send(body=\"test\", attach=attach) is True\n    assert bad.closed is True\n\n    mock_post.reset_mock()\n    mock_open.reset_mock()\n\n    mock_sleep.reset_mock()\n    mock_sleep.return_value = True\n\n    webhook_id, webhook_token = _tokens()\n    obj = NotifyFluxer(webhook_id=webhook_id, webhook_token=webhook_token)\n\n    attach_path = os.path.join(TEST_VAR_DIR, \"apprise-test.png\")\n    attach = AppriseAttachment(attach_path)\n\n    class _GoodCloseIO:\n        def __init__(self) -> None:\n            self.closed = False\n\n        def close(self) -> None:\n            self.closed = True\n\n    good = _GoodCloseIO()\n    mock_open.return_value = good\n\n    def _resp(code: int, headers: dict[str, str] | None = None) -> mock.Mock:\n        r = mock.Mock()\n        r.status_code = code\n        r.content = b\"\"\n        r.headers = headers or {}\n        return r\n\n    mock_post.side_effect = [\n        _resp(requests.codes.no_content, {}),\n        _resp(requests.codes.too_many_requests, {\"Retry-After\": \"1\"}),\n        _resp(requests.codes.no_content, {}),\n    ]\n\n    assert obj.send(body=\"test\", attach=attach) is True\n    assert good.closed is True\n"
  },
  {
    "path": "tests/test_plugin_fortysixelks.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.fortysixelks import Notify46Elks\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\"46elks://\", False),\n    (\"46elks://user@/\", False),\n    (\"46elks://:pass@/\", False),\n\n    (\"46elks://user:pass@/\", {\n        \"instance\": Notify46Elks,\n        # no target was specified\n        \"notify_response\": False,\n    }),\n\n    (\"46elks://user:pass@+15551234556\", {\n        \"instance\": Notify46Elks,\n    }),\n\n    (\"46elks://user:pass@+15551234567/+46701234534?from=Acme\", {\n        \"instance\": Notify46Elks,\n    }),\n\n    # Support elks:// too!\n    (\"elks://user:pass@+15551234123/\", {\n        \"instance\": Notify46Elks,\n    }),\n\n    # Privacy mode redacts password\n    (\"46elks://user:pass@+15551234512\", {\n        \"privacy_url\": \"46elks://user:****@+15551234512\",\n        \"instance\": Notify46Elks,\n    }),\n\n    # invalid phone no\n    (\"46elks://user:pass@Acme/234512\", {\n        \"instance\": Notify46Elks,\n        \"notify_response\": False,\n    }),\n    # Native URL reversal\n    ((\"https://user1:pass@\"\n      \"api.46elks.com/a1/sms?to=+15551234511&from=Acme\"), {\n        \"instance\": Notify46Elks,\n        \"privacy_url\": \"46elks://user1:****@Acme/+15551234511\",\n    }),\n    (\"46elks://user:pass@+15551234567\",\n        {\n            \"instance\": Notify46Elks,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        }),\n    (\"46elks://user:pass@+15551234578\",\n        {\n            \"instance\": Notify46Elks,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        }),\n)\n\n\ndef test_plugin_46elks_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_46elks_edge_cases(mock_post):\n    \"\"\"Notify46Elks() Edge Cases.\"\"\"\n\n    user = \"user1\"\n    password = \"pass123\"\n    phone = \"+15551234591\"\n\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    obj = Apprise.instantiate(f\"46elks://{user}:{password}@{phone}\")\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # We know there is 1 (valid) targets\n    assert len(obj) == 1\n\n    # Test our call count\n    assert mock_post.call_count == 1\n\n    # Test\n    details = mock_post.call_args_list[0]\n    headers = details[1][\"headers\"]\n    assert headers[\"User-Agent\"] == \"Apprise\"\n    payload = details[1][\"data\"]\n    assert payload[\"to\"] == phone\n    assert payload[\"from\"] == phone\n    assert payload[\"message\"] == \"title\\r\\nbody\"\n\n    # Verify our URL looks good\n    assert obj.url().startswith(f\"46elks://{user}:{password}@{phone}\")\n"
  },
  {
    "path": "tests/test_plugin_freemobile.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.freemobile import NotifyFreeMobile\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"freemobile://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"freemobile://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"freemobile://user@password\",\n        {\n            # Minimum requirements met\n            \"instance\": NotifyFreeMobile,\n        },\n    ),\n    (\n        \"freemobile://?user=user&pass=password\",\n        {\n            # Test ?user= and pass=\n            \"instance\": NotifyFreeMobile,\n        },\n    ),\n    (\n        \"freemobile://user@password\",\n        {\n            \"instance\": NotifyFreeMobile,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"freemobile://user@password\",\n        {\n            \"instance\": NotifyFreeMobile,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"freemobile://user@password\",\n        {\n            \"instance\": NotifyFreeMobile,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_freemobile_urls():\n    \"\"\"NotifyFreeMobile() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_glib.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport sys\nimport types\nfrom unittest.mock import Mock, call\n\nfrom helpers import reload_plugin\nimport pytest\n\nimport apprise\nfrom apprise.plugins.glib import GLibUrgency, NotifyGLib\n\n# Disable logging output during testing\nlogging.disable(logging.CRITICAL)\n\n\n@pytest.fixture\ndef enabled_glib_environment(monkeypatch):\n    \"\"\"\n    Fully mocked GI/GLib/Gio/GdkPixbuf environment for local and CI.\n    \"\"\"\n    # Step 1: Fake gi and repository\n    gi = types.ModuleType(\"gi\")\n    gi.require_version = Mock()\n\n    fake_variant = Mock(name=\"Variant\")\n    fake_error = type(\"GLibError\", (Exception,), {})\n    fake_pixbuf = Mock()\n    fake_image = Mock()\n\n    fake_pixbuf.new_from_file.return_value = fake_image\n    fake_image.get_width.return_value = 100\n    fake_image.get_height.return_value = 100\n    fake_image.get_rowstride.return_value = 1\n    fake_image.get_has_alpha.return_value = False\n    fake_image.get_bits_per_sample.return_value = 8\n    fake_image.get_n_channels.return_value = 1\n    fake_image.get_pixels.return_value = b\"\"\n\n    gi.repository = types.SimpleNamespace(\n        Gio=Mock(),\n        GLib=types.SimpleNamespace(Variant=fake_variant, Error=fake_error),\n        GdkPixbuf=types.SimpleNamespace(Pixbuf=fake_pixbuf),\n    )\n\n    # Step 2: Inject into sys.modules\n    sys.modules[\"gi\"] = gi\n    sys.modules[\"gi.repository\"] = gi.repository\n\n    # Step 3: Reload plugin with all mocks in place\n    reload_plugin(\"glib\")\n\n\ndef test_plugin_glib_gdkpixbuf_attribute_error(monkeypatch):\n    \"\"\"Simulate AttributeError from importing GdkPixbuf\"\"\"\n\n    # Create gi module\n    gi = types.ModuleType(\"gi\")\n\n    # Create gi.repository mock, but DO NOT include GdkPixbuf\n    gi.repository = types.SimpleNamespace(\n        Gio=Mock(),\n        GLib=types.SimpleNamespace(\n            Variant=Mock(),\n            Error=type(\"GLibError\", (Exception,), {})\n        ),\n        # GdkPixbuf missing entirely triggers AttributeError\n    )\n\n    def fake_require_version(name, version):\n        if name == \"GdkPixbuf\":\n            # Simulate success in require_version\n            return\n        return\n\n    gi.require_version = Mock(side_effect=fake_require_version)\n\n    # Inject into sys.modules\n    sys.modules[\"gi\"] = gi\n    sys.modules[\"gi.repository\"] = gi.repository\n\n    # Trigger the plugin reload with our patched environment\n    reload_plugin(\"glib\")\n\n    from apprise.plugins import glib as plugin_glib\n    assert plugin_glib.NOTIFY_GLIB_IMAGE_SUPPORT is False\n\n\ndef test_plugin_glib_basic_notify(enabled_glib_environment):\n    \"\"\"Basic notification path\"\"\"\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    assert isinstance(obj, NotifyGLib)\n    assert obj.notify(\"body\", title=\"title\") is True\n\n\ndef test_plugin_glib_url_includes_coordinates(enabled_glib_environment):\n    \"\"\"Test that x/y coordinates appear in the rendered URL.\"\"\"\n    obj = apprise.Apprise.instantiate(\n        \"glib://_/?x=7&y=9\", suppress_exceptions=False)\n    url = obj.url(privacy=False)\n\n    assert \"x=7\" in url\n    assert \"y=9\" in url\n\n\ndef test_plugin_glib_icon_fails_gracefully(mocker, enabled_glib_environment):\n    \"\"\"Simulate image load failure\"\"\"\n    import gi\n    gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = \\\n        AttributeError(\"fail\")\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    spy = mocker.spy(obj, \"logger\")\n    assert obj.notify(\"msg\", title=\"t\") is True\n    assert any(\"Could not load notification icon\" in str(x)\n               for x in spy.warning.call_args_list)\n\n\ndef test_plugin_glib_send_raises_glib_error(mocker, enabled_glib_environment):\n    \"\"\"Simulate GLib.Error in DBusProxy creation\"\"\"\n    import gi\n    gi.repository.Gio.DBusProxy.new_for_bus_sync.side_effect = \\\n        gi.repository.GLib.Error(\"fail\")\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    assert obj.notify(\"fail test\") is False\n\n\ndef test_plugin_glib_send_raises_generic(mocker, enabled_glib_environment):\n    \"\"\"Simulate generic error in gio_iface.Notify()\"\"\"\n    # Re: https://github.com/caronc/apprise/issues/1383\n    # This test validates that the NotifyGLib plugin correctly handles a\n    # generic exception raised by the `Notify()` method call on a mocked\n    # DBus interface. However, it is only meaningful in environments that:\n    #\n    #  1. Do NOT have PyGObject (`gi`) installed, OR\n    #  2. Have `gi`, but without introspection or live bindings activated.\n    #\n    # When PyGObject is installed and active, the `gi.repository` namespace\n    # becomes populated by introspected C-based objects that do not behave\n    # like regular Python functions. This causes mock patching via\n    # `mocker.patch(\"gi.repository.Gio.DBusProxy.new_for_bus_sync\")` to\n    # silently fail or be ignored, as Python's mocking machinery cannot\n    # reliably override these introspected symbols.\n    #\n    # This test exists to ensure coverage of legacy or minimal environments\n    # where Apprise's GLib support can still be used through soft mocks,\n    # such as CI/CD pipelines or headless test setups where PyGObject is\n    # absent or stubbed (as done via `enabled_glib_environment`).\n    #\n    # Note: In production environments with active PyGObject, exception\n    # handling is already tested via `GLib.Error` branches or during actual\n    # usage of `NotifyGLib.send()`. This test supplements that by simulating\n    # the rare fallback case of a non-GLib-related exception during Notify().\n    import gi\n    if hasattr(gi, \"repository\"):\n        pytest.skip(\n            \"pygobject introspection active, test won't behave as expected\")\n\n    fake_iface = Mock()\n    fake_iface.Notify.side_effect = RuntimeError(\"boom\")\n\n    mocker.patch(\n        \"gi.repository.Gio.DBusProxy.new_for_bus_sync\",\n        return_value=fake_iface,\n    )\n\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    logger = mocker.spy(obj, \"logger\")\n    assert obj.notify(\"boom\", title=\"fail\") is False\n    logger.warning.assert_called_with(\"Failed to send GLib/Gio notification.\")\n\n\ndef test_plugin_glib_disabled(mocker, enabled_glib_environment):\n    \"\"\"Test disabled plugin returns False on notify()\"\"\"\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    obj.enabled = False\n    assert obj.notify(\"x\") is False\n\n\ndef test_plugin_glib_invalid_coords():\n    \"\"\"Invalid x/y coordinates cause TypeError\"\"\"\n    with pytest.raises(TypeError):\n        NotifyGLib(x_axis=\"bad\", y_axis=\"1\")\n    with pytest.raises(TypeError):\n        NotifyGLib(x_axis=\"1\", y_axis=\"bad\")\n\n\ndef test_plugin_glib_urgency_parsing():\n    \"\"\"Urgency variants map correctly\"\"\"\n    assert NotifyGLib(urgency=\"high\").urgency == GLibUrgency.HIGH\n    assert NotifyGLib(urgency=\"invalid\").urgency == GLibUrgency.NORMAL\n    assert NotifyGLib(urgency=\"2\").urgency == GLibUrgency.HIGH\n    assert NotifyGLib(urgency=0).urgency == GLibUrgency.LOW\n\n\ndef test_plugin_glib_parse_url_fields():\n    url = \"glib://_/?x=5&y=5&image=no&priority=high\"\n    result = NotifyGLib.parse_url(url)\n    assert result[\"x_axis\"] == \"5\"\n    assert result[\"y_axis\"] == \"5\"\n    assert result[\"include_image\"] is False\n    assert result[\"urgency\"] == \"high\"\n\n\ndef test_plugin_glib_xy_axis_applied_to_variant(enabled_glib_environment):\n    \"\"\"Ensure x/y values are added to GLib.Variant payload.\"\"\"\n    obj = apprise.Apprise.instantiate(\n        \"glib://_/?x=5&y=10\", suppress_exceptions=False)\n\n    # Patch GLib.Variant to track calls\n    import gi\n    spy_variant = Mock(wraps=gi.repository.GLib.Variant)\n    gi.repository.GLib.Variant = spy_variant\n\n    assert obj.notify(\"Test with coords\", title=\"xy\") is True\n\n    # Check x and y were added to meta_payload\n    assert call(\"i\", 5) in spy_variant.mock_calls\n    assert call(\"i\", 10) in spy_variant.mock_calls\n\n\ndef test_plugin_glib_no_image_support(monkeypatch, enabled_glib_environment):\n    \"\"\"Simulate GdkPixbuf unavailable\"\"\"\n    monkeypatch.setattr(\n        \"apprise.plugins.glib.NOTIFY_GLIB_IMAGE_SUPPORT\", False)\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    assert obj.notify(\"no image\") is True\n\n\ndef test_plugin_glib_url_redaction(enabled_glib_environment):\n    \"\"\"url() privacy mode redacts safely\"\"\"\n    obj = apprise.Apprise.instantiate(\n        \"glib://_/?image=no&urgency=high\", suppress_exceptions=False)\n    url = obj.url(privacy=True)\n    assert \"image=\" in url\n    assert \"urgency=\" in url\n    assert url.startswith(\"glib://_/\")\n\n\ndef test_plugin_glib_require_version_importerror(monkeypatch):\n    \"\"\"Simulate gi.require_version() raising ImportError\"\"\"\n    gi = types.ModuleType(\"gi\")\n    gi.require_version = Mock(side_effect=ImportError(\"no gio\"))\n    sys.modules[\"gi\"] = gi\n    reload_plugin(\"glib\")\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    assert not isinstance(obj, NotifyGLib)\n\n\ndef test_plugin_glib_require_version_valueerror(monkeypatch):\n    \"\"\"Simulate gi.require_version() raising ValueError without reload\n    crash.\"\"\"\n\n    import gi\n\n    import apprise.plugins.glib as plugin_glib\n\n    # Patch require_version after import\n    monkeypatch.setattr(\n        gi, \"require_version\", Mock(side_effect=ValueError(\"fail\")))\n\n    # Re-evaluate plugin support logic manually\n    try:\n        gi.require_version(\"Gio\", \"2.0\")\n\n    except Exception:\n        plugin_glib.NOTIFY_GLIB_SUPPORT_ENABLED = False\n        plugin_glib.NotifyGLib.enabled = False\n\n    # Confirm plugin is now marked disabled\n    assert not plugin_glib.NotifyGLib.enabled\n\n    # Apprise will skip this plugin\n    obj = apprise.Apprise.instantiate(\"glib://\", suppress_exceptions=False)\n    assert not isinstance(obj, plugin_glib.NotifyGLib)\n\n\ndef test_plugin_glib_gdkpixbuf_require_version_valueerror(monkeypatch):\n    \"\"\"Simulate gi.require_version('GdkPixbuf', ...) raising ValueError\"\"\"\n\n    # Step 1: Mock GI\n    gi = types.ModuleType(\"gi\")\n    gi.repository = types.ModuleType(\"gi.repository\")\n\n    def fake_require_version(name: str, version: str) -> None:\n        if name == \"GdkPixbuf\":\n            raise ValueError(\"GdkPixbuf unavailable\")\n\n    gi.require_version = Mock(side_effect=fake_require_version)\n\n    # Step 2: Patch into sys.modules\n    sys.modules[\"gi\"] = gi\n    sys.modules[\"gi.repository\"] = gi.repository\n\n    # Step 3: Reload plugin to trigger branch\n    reload_plugin(\"glib\")\n\n    # Step 4: Confirm GdkPixbuf image support was not enabled\n    from apprise.plugins import glib as plugin_glib\n    assert plugin_glib.NOTIFY_GLIB_IMAGE_SUPPORT is False\n\n\ndef test_plugin_glib_notify_generic_exception(\n        mocker, enabled_glib_environment):\n    \"\"\"\n    Test that a generic exception occurring during the notification send\n    is caught and handled (returning False).\n    \"\"\"\n    import gi\n\n    # 1. Create a mock object to act as the DBus Proxy\n    mock_proxy = mocker.Mock()\n\n    # 2. Force the Notify method to raise a generic Exception\n    #    This triggers the 'except Exception as e:'\n    mock_proxy.Notify.side_effect = Exception(\"Simulated Generic Failure\")\n\n    # 3. Patch the Gio constructor to return our crashing mock\n    #    The code calls: Gio.DBusProxy.new_for_bus_sync(...)\n    gi.repository.Gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy\n\n    # 4. Instantiate the plugin\n    from apprise.plugins.glib import NotifyGLib\n    obj = NotifyGLib(targets=[\"glib://\"])\n\n    # 5. Spy on the logger to ensure the warning is logged\n    logger_spy = mocker.spy(obj, \"logger\")\n\n    # 6. Execute notify()\n    #    It should catch the exception and return False\n    assert obj.notify(title=\"Title\", body=\"Body\") is False\n\n    # 7. Verify the specific log message from glib.py\n    logger_spy.warning.assert_called_with(\n        \"Failed to send GLib/Gio notification.\")\n"
  },
  {
    "path": "tests/test_plugin_gnome.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport importlib\nimport logging\nimport sys\nimport types\nfrom unittest import mock\nfrom unittest.mock import ANY, Mock, call\n\nfrom helpers import reload_plugin\nimport pytest\n\nimport apprise\nfrom apprise.plugins.gnome import GnomeUrgency, NotifyGnome\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n\ndef setup_glib_environment():\n    \"\"\"Setup a heavily mocked Glib environment.\"\"\"\n\n    # Our module base\n    gi_name = \"gi\"\n\n    # First we do an import without the gi library available to ensure\n    # we can handle cases when the library simply isn't available\n\n    if gi_name in sys.modules:\n        # Test cases where the gi library exists; we want to remove it\n        # for the purpose of testing and capture the handling of the\n        # library when it is missing\n        del sys.modules[gi_name]\n        reload_plugin(\"gnome\")\n\n    # We need to fake our gnome environment for testing purposes since\n    # the gi library isn't available on CI\n    gi = types.ModuleType(gi_name)\n    gi.repository = types.ModuleType(gi_name + \".repository\")\n    gi.module = types.ModuleType(gi_name + \".module\")\n\n    mock_pixbuf = mock.Mock()\n    mock_notify = mock.Mock()\n\n    gi.repository.GdkPixbuf = types.ModuleType(\n        gi_name + \".repository.GdkPixbuf\"\n    )\n    gi.repository.GdkPixbuf.Pixbuf = mock_pixbuf\n    gi.repository.Notify = mock.Mock()\n    gi.repository.Notify.init.return_value = True\n    gi.repository.Notify.Notification = mock_notify\n\n    # Emulate require_version function:\n    gi.require_version = mock.Mock(name=gi_name + \".require_version\")\n\n    # Force the fake module to exist\n    sys.modules[gi_name] = gi\n    sys.modules[gi_name + \".repository\"] = gi.repository\n    sys.modules[gi_name + \".repository.Notify\"] = gi.repository.Notify\n\n    # Notify Object\n    notify_obj = mock.Mock()\n    notify_obj.set_urgency.return_value = True\n    notify_obj.set_icon_from_pixbuf.return_value = True\n    notify_obj.set_image_from_pixbuf.return_value = True\n    notify_obj.show.return_value = True\n    mock_notify.new.return_value = notify_obj\n    mock_pixbuf.new_from_file.return_value = True\n\n    # When patching something which has a side effect on the module-level code\n    # of a plugin, make sure to reload it.\n    reload_plugin(\"gnome\")\n\n\n@pytest.fixture\ndef glib_environment():\n    \"\"\"Fixture to provide a mocked Glib environment to test case functions.\"\"\"\n    setup_glib_environment()\n\n\n@pytest.fixture\ndef obj(glib_environment):\n    \"\"\"Fixture to provide a mocked Apprise instance.\"\"\"\n\n    # Create our instance\n    obj = apprise.Apprise.instantiate(\"gnome://\", suppress_exceptions=False)\n    assert obj is not None\n    assert isinstance(obj, NotifyGnome) is True\n\n    # Set our duration to 0 to speed up timeouts (for testing)\n    obj.duration = 0\n\n    # Check that it found our mocked environments\n    assert obj.enabled is True\n\n    return obj\n\n\ndef test_plugin_gnome_general_success(obj):\n    \"\"\"NotifyGnome() general checks.\"\"\"\n\n    # Test url() call\n    assert isinstance(obj.url(), str) is True\n\n    # our URL Identifier is disabled\n    assert obj.url_id() is None\n\n    # test notifications\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    # test notification without a title\n    assert (\n        obj.notify(title=\"\", body=\"body\", notify_type=apprise.NotifyType.INFO)\n        is True\n    )\n\n\ndef test_plugin_gnome_image_success(glib_environment):\n    \"\"\"Verify using the `image` query argument works as intended.\"\"\"\n\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?image=True\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyGnome) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?image=False\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyGnome) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n\ndef test_plugin_gnome_priority(glib_environment):\n    \"\"\"Verify correctness of the `priority` query argument.\"\"\"\n\n    # Test Priority (alias of urgency)\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?priority=invalid\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyGnome) is True\n    assert obj.urgency == 1\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?priority=high\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyGnome) is True\n    assert obj.urgency == 2\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?priority=2\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyGnome) is True\n    assert obj.urgency == 2\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n\ndef test_plugin_gnome_urgency(glib_environment):\n    \"\"\"Verify correctness of the `urgency` query argument.\"\"\"\n\n    # Test Urgeny\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?urgency=invalid\", suppress_exceptions=False\n    )\n    assert obj.urgency == 1\n    assert isinstance(obj, NotifyGnome) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?urgency=high\", suppress_exceptions=False\n    )\n    assert obj.urgency == 2\n    assert isinstance(obj, NotifyGnome) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"gnome://_/?urgency=2\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyGnome) is True\n    assert obj.urgency == 2\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n\ndef test_plugin_gnome_parse_configuration(obj):\n    \"\"\"Verify configuration parsing works correctly.\"\"\"\n\n    # Test configuration parsing\n    content = \"\"\"\n    urls:\n      - gnome://:\n          - priority: 0\n            tag: gnome_int low\n          - priority: \"0\"\n            tag: gnome_str_int low\n          - priority: low\n            tag: gnome_str low\n          - urgency: 0\n            tag: gnome_int low\n          - urgency: \"0\"\n            tag: gnome_str_int low\n          - urgency: low\n            tag: gnome_str low\n\n          # These will take on normal (default) urgency\n          - priority: invalid\n            tag: gnome_invalid\n          - urgency: invalid\n            tag: gnome_invalid\n\n      - gnome://:\n          - priority: 2\n            tag: gnome_int high\n          - priority: \"2\"\n            tag: gnome_str_int high\n          - priority: high\n            tag: gnome_str high\n          - urgency: 2\n            tag: gnome_int high\n          - urgency: \"2\"\n            tag: gnome_str_int high\n          - urgency: high\n            tag: gnome_str high\n    \"\"\"\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 14 servers from that\n    # 6x low\n    # 6x high\n    # 2x invalid (so takes on normal urgency)\n    assert len(ac.servers()) == 14\n    assert len(aobj) == 14\n    assert len(list(aobj.find(tag=\"low\"))) == 6\n    for s in aobj.find(tag=\"low\"):\n        assert s.urgency == GnomeUrgency.LOW\n\n    assert len(list(aobj.find(tag=\"high\"))) == 6\n    for s in aobj.find(tag=\"high\"):\n        assert s.urgency == GnomeUrgency.HIGH\n\n    assert len(list(aobj.find(tag=\"gnome_str\"))) == 4\n    assert len(list(aobj.find(tag=\"gnome_str_int\"))) == 4\n    assert len(list(aobj.find(tag=\"gnome_int\"))) == 4\n\n    assert len(list(aobj.find(tag=\"gnome_invalid\"))) == 2\n    for s in aobj.find(tag=\"gnome_invalid\"):\n        assert s.urgency == GnomeUrgency.NORMAL\n\n\ndef test_plugin_gnome_missing_icon(mocker, obj):\n    \"\"\"Verify the notification will be submitted, even if loading the icon\n    fails.\"\"\"\n\n    # Inject error when loading icon.\n    gi = importlib.import_module(\"gi\")\n    gi.repository.GdkPixbuf.Pixbuf.new_from_file.side_effect = AttributeError(\n        \"Something failed\"\n    )\n\n    logger: Mock = mocker.spy(obj, \"logger\")\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n    assert logger.mock_calls == [\n        call.warning(\"Could not load notification icon (%s).\", ANY),\n        call.debug(\"Gnome Exception: Something failed\"),\n        call.info(\"Sent Gnome notification.\"),\n    ]\n\n\ndef test_plugin_gnome_disabled_plugin(obj):\n    \"\"\"Verify notification will not be submitted if plugin is disabled.\"\"\"\n    obj.enabled = False\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n\n\ndef test_plugin_gnome_set_urgency():\n    \"\"\"Test the setting of an urgency, through `priority` keyword argument.\"\"\"\n    NotifyGnome(priority=0)\n\n\n@pytest.mark.skipif(\"gi\" not in sys.modules, reason=\"Requires gi library\")\ndef test_plugin_gnome_gi_croaks():\n    \"\"\"Verify notification fails when `gi.require_version()` croaks.\"\"\"\n\n    # Make `require_version` function raise an error.\n    gi = importlib.import_module(\"gi\")\n    gi.require_version.side_effect = ValueError(\"Something failed\")\n\n    # When patching something which has a side effect on the module-level code\n    # of a plugin, make sure to reload it.\n    reload_plugin(\"gnome\")\n\n    # Create instance.\n    obj = apprise.Apprise.instantiate(\"gnome://\", suppress_exceptions=False)\n\n    # The notifier is marked disabled.\n    assert obj is None\n\n\n@pytest.mark.skipif(\"gi\" not in sys.modules, reason=\"Requires gi library\")\ndef test_plugin_gnome_notify_croaks(mocker, obj):\n    \"\"\"Fail gracefully if underlying object croaks for whatever reason.\"\"\"\n\n    # Inject an error when invoking `gi.repository.Notify`.\n    mocker.patch(\n        \"gi.repository.Notify.Notification.new\",\n        side_effect=AttributeError(\"Something failed\"),\n    )\n\n    logger: Mock = mocker.spy(obj, \"logger\")\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n    assert logger.mock_calls == [\n        call.warning(\"Failed to send Gnome notification.\"),\n        call.debug(\"Gnome Exception: Something failed\"),\n    ]\n\n\ndef test_plugin_gnome_show_exception(mocker, obj):\n    \"\"\"\n    Test that an exception raised during notification.show() is caught\n    by the outer try/except block.\n    \"\"\"\n    # 1. Import the mocked 'gi' library.\n    #    The 'obj' fixture (via 'glib_environment') has already set this up\n    #    in sys.modules, so this import works even if Gnome isn't installed.\n    import gi\n\n    # 2. Retrieve the mocked notification instance.\n    #    Your setup_glib_environment() sets\n    #    'gi.repository.Notify.Notification.new' to return a mock object.\n    notification_instance = gi.repository.Notify.Notification.new.return_value\n\n    # 3. Force the .show() method to raise an exception.\n    notification_instance.show.side_effect = \\\n        Exception(\"Simulated Gnome Show Failure\")\n\n    # 4. Spy on the logger to verify the output.\n    logger_spy = mocker.spy(obj, \"logger\")\n\n    # 5. Execute the notification.\n    #    It should crash at .show(), catch the exception, and return False.\n    assert obj.notify(title=\"Title\", body=\"Body\") is False\n\n    # 6. Verify the specific log message.\n    logger_spy.warning.assert_called_with(\"Failed to send Gnome notification.\")\n"
  },
  {
    "path": "tests/test_plugin_google_chat.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.google_chat import NotifyGoogleChat\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"gchat://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"gchat://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Workspace, but not Key or Token\n    (\n        \"gchat://workspace\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Workspace and key, but no Token\n    (\n        \"gchat://workspace/key/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Credentials are good\n    (\n        \"gchat://workspace/key/token\",\n        {\n            \"instance\": NotifyGoogleChat,\n            \"privacy_url\": \"gchat://w...e/k...y/t...n\",\n        },\n    ),\n    # Test arguments\n    (\n        \"gchat://?workspace=ws&key=mykey&token=mytoken\",\n        {\n            \"instance\": NotifyGoogleChat,\n            \"privacy_url\": \"gchat://w...s/m...y/m...n\",\n        },\n    ),\n    (\n        \"gchat://?workspace=ws&key=mykey&token=mytoken&thread=abc123\",\n        {\n            # Test our thread key\n            \"instance\": NotifyGoogleChat,\n            \"privacy_url\": \"gchat://w...s/m...y/m...n/a...3\",\n        },\n    ),\n    (\n        \"gchat://?workspace=ws&key=mykey&token=mytoken&threadKey=abc345\",\n        {\n            # Test our thread key\n            \"instance\": NotifyGoogleChat,\n            \"privacy_url\": \"gchat://w...s/m...y/m...n/a...5\",\n        },\n    ),\n    # Google Native Webhook URL\n    (\n        (\n            \"https://chat.googleapis.com/v1/spaces/myworkspace/messages\"\n            \"?key=mykey&token=mytoken\"\n        ),\n        {\n            \"instance\": NotifyGoogleChat,\n            \"privacy_url\": \"gchat://m...e/m...y/m...n\",\n        },\n    ),\n    (\n        (\n            \"https://chat.googleapis.com/v1/spaces/myworkspace/messages\"\n            \"?key=mykey&token=mytoken&threadKey=mythreadkey\"\n        ),\n        {\n            \"instance\": NotifyGoogleChat,\n            \"privacy_url\": \"gchat://m...e/m...y/m...n/m...y\",\n        },\n    ),\n    (\n        \"gchat://workspace/key/token\",\n        {\n            \"instance\": NotifyGoogleChat,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"gchat://workspace/key/token\",\n        {\n            \"instance\": NotifyGoogleChat,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"gchat://workspace/key/token\",\n        {\n            \"instance\": NotifyGoogleChat,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_google_chat_urls():\n    \"\"\"NotifyGoogleChat() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_google_chat_general(mock_post):\n    \"\"\"NotifyGoogleChat() General Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    workspace = \"ws\"\n    key = \"key\"\n    threadkey = \"threadkey\"\n    token = \"token\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Test our messaging\n    obj = Apprise.instantiate(f\"gchat://{workspace}/{key}/{token}\")\n    assert isinstance(obj, NotifyGoogleChat)\n    assert (\n        obj.notify(\n            body=\"test body\", title=\"title\", notify_type=NotifyType.INFO\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://chat.googleapis.com/v1/spaces/ws/messages\"\n    )\n    params = mock_post.call_args_list[0][1][\"params\"]\n    assert params.get(\"token\") == token\n    assert params.get(\"key\") == key\n    assert \"messageReplyOption\" not in params\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert \"thread\" not in payload\n    assert payload[\"text\"] == \"title\\r\\ntest body\"\n\n    mock_post.reset_mock()\n\n    # Test our messaging with the thread_key\n    obj = Apprise.instantiate(f\"gchat://{workspace}/{key}/{token}/{threadkey}\")\n    assert isinstance(obj, NotifyGoogleChat)\n    assert (\n        obj.notify(\n            body=\"test body\", title=\"title\", notify_type=NotifyType.INFO\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://chat.googleapis.com/v1/spaces/ws/messages\"\n    )\n    params = mock_post.call_args_list[0][1][\"params\"]\n    assert params.get(\"token\") == token\n    assert params.get(\"key\") == key\n    assert params.get(\"messageReplyOption\") == \\\n        \"REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD\"\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert \"thread\" in payload\n    assert payload[\"text\"] == \"title\\r\\ntest body\"\n    assert payload[\"thread\"].get(\"thread_key\") == threadkey\n\n\ndef test_plugin_google_chat_edge_case():\n    \"\"\"NotifyGoogleChat() Edge Cases.\"\"\"\n    with pytest.raises(TypeError):\n        NotifyGoogleChat(\"workspace\", \"webhook\", \"token\", thread_key=object())\n"
  },
  {
    "path": "tests/test_plugin_gotify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nimport apprise\nfrom apprise.plugins.gotify import GotifyPriority, NotifyGotify\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"gotify://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # No token specified\n    (\n        \"gotify://hostname\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Provide a hostname and token\n    (\n        \"gotify://hostname/%s\" % (\"t\" * 16),\n        {\n            \"instance\": NotifyGotify,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"gotify://hostname/t...t\",\n        },\n    ),\n    # Provide a hostname, path, and token\n    (\n        \"gotify://hostname/a/path/ending/in/a/slash/%s\" % (\"u\" * 16),\n        {\n            \"instance\": NotifyGotify,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"gotify://hostname/a/path/ending/in/a/slash/u...u/\",\n        },\n    ),\n    # Markdown test\n    (\n        \"gotify://hostname/%s?format=markdown\" % (\"t\" * 16),\n        {\n            \"instance\": NotifyGotify,\n        },\n    ),\n    # Provide a hostname, path, and token\n    (\n        \"gotify://hostname/a/path/not/ending/in/a/slash/%s\" % (\"v\" * 16),\n        {\n            \"instance\": NotifyGotify,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"gotify://hostname/a/path/not/ending/in/a/slash/v...v/\"\n            ),\n        },\n    ),\n    # Provide a priority\n    (\n        \"gotify://hostname/%s?priority=high\" % (\"i\" * 16),\n        {\n            \"instance\": NotifyGotify,\n        },\n    ),\n    # Provide an invalid priority\n    (\n        \"gotify://hostname:8008/%s?priority=invalid\" % (\"i\" * 16),\n        {\n            \"instance\": NotifyGotify,\n        },\n    ),\n    # An invalid url\n    (\n        \"gotify://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"gotify://hostname/%s/\" % (\"t\" * 16),\n        {\n            \"instance\": NotifyGotify,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"gotifys://localhost/%s/\" % (\"t\" * 16),\n        {\n            \"instance\": NotifyGotify,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"gotify://localhost/%s/\" % (\"t\" * 16),\n        {\n            \"instance\": NotifyGotify,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_gotify_urls():\n    \"\"\"NotifyGotify() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_gotify_edge_cases():\n    \"\"\"NotifyGotify() Edge Cases.\"\"\"\n    # Initializes the plugin with an invalid token\n    with pytest.raises(TypeError):\n        NotifyGotify(token=None)\n    # Whitespace also acts as an invalid token value\n    with pytest.raises(TypeError):\n        NotifyGotify(token=\"   \")\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_gotify_config_files(mock_post):\n    \"\"\"NotifyGotify() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - gotify://hostname/{}:\n          - priority: 0\n            tag: gotify_int low\n          - priority: \"0\"\n            tag: gotify_str_int low\n          # We want to make sure our '1' does not match the '10' entry\n          - priority: \"1\"\n            tag: gotify_str_int low\n          - priority: low\n            tag: gotify_str low\n\n          # This will take on moderate (default) priority\n          - priority: invalid\n            tag: gotify_invalid\n\n      - gotify://hostname/{}:\n          - priority: 10\n            tag: gotify_int emerg\n          - priority: \"10\"\n            tag: gotify_str_int emerg\n          - priority: emergency\n            tag: gotify_str emerg\n    \"\"\".format(\"a\" * 16, \"b\" * 16)\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 8 servers from that\n    # 4x low\n    # 3x emerg\n    # 1x invalid (so takes on normal priority)\n    assert len(ac.servers()) == 8\n    assert len(aobj) == 8\n    assert len(list(aobj.find(tag=\"low\"))) == 4\n    for s in aobj.find(tag=\"low\"):\n        assert s.priority == GotifyPriority.LOW\n\n    assert len(list(aobj.find(tag=\"emerg\"))) == 3\n    for s in aobj.find(tag=\"emerg\"):\n        assert s.priority == GotifyPriority.EMERGENCY\n\n    assert len(list(aobj.find(tag=\"gotify_str\"))) == 2\n    assert len(list(aobj.find(tag=\"gotify_str_int\"))) == 3\n    assert len(list(aobj.find(tag=\"gotify_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"gotify_invalid\"))) == 1\n    assert (\n        next(aobj.find(tag=\"gotify_invalid\")).priority == GotifyPriority.NORMAL\n    )\n"
  },
  {
    "path": "tests/test_plugin_growl.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport sys\nfrom unittest import mock\n\nimport pytest\n\nimport apprise\nfrom apprise import NotifyBase\nfrom apprise.plugins.growl import GrowlPriority, NotifyGrowl\n\ntry:\n    from gntp import errors\n\n    TEST_GROWL_EXCEPTIONS = (\n        errors.NetworkError(0, \"gntp.ParseError() not handled\"),\n        errors.AuthError(0, \"gntp.AuthError() not handled\"),\n        errors.ParseError(0, \"gntp.ParseError() not handled\"),\n        errors.UnsupportedError(0, \"gntp.UnsupportedError() not handled\"),\n    )\n\nexcept ImportError:\n    # no problem; gntp isn't available to us\n    pass\n\n# Disable logging for a cleaner testing output\nimport logging\n\nlogging.disable(logging.CRITICAL)\n\n\n@pytest.mark.skipif(\n    \"gntp\" in sys.modules, reason=\"Requires that gntp NOT be installed\"\n)\ndef test_plugin_growl_gntp_import_error():\n    \"\"\"NotifyGrowl() Import Error.\"\"\"\n    # If the object is disabled, then it can't be instantiated\n    obj = apprise.Apprise.instantiate(\"growl://growl.server\")\n    assert obj is None\n\n\n@pytest.mark.skipif(\"gntp\" not in sys.modules, reason=\"Requires gntp\")\n@mock.patch(\"gntp.notifier.GrowlNotifier\")\ndef test_plugin_growl_exception_handling(mock_gntp):\n    \"\"\"NotifyGrowl() Exception Handling.\"\"\"\n    TEST_GROWL_EXCEPTIONS = (\n        errors.NetworkError(0, \"gntp.ParseError() not handled\"),\n        errors.AuthError(0, \"gntp.AuthError() not handled\"),\n        errors.ParseError(0, \"gntp.ParseError() not handled\"),\n        errors.UnsupportedError(0, \"gntp.UnsupportedError() not handled\"),\n    )\n\n    mock_notifier = mock.Mock()\n    mock_gntp.return_value = mock_notifier\n    mock_notifier.notify.return_value = True\n\n    # First we test the growl.register() function\n    for exception in TEST_GROWL_EXCEPTIONS:\n        mock_notifier.register.side_effect = exception\n\n        # instantiate our object\n        obj = apprise.Apprise.instantiate(\n            \"growl://growl.server.hostname\", suppress_exceptions=False\n        )\n\n        # Verify Growl object was instantiated\n        assert obj is not None\n\n        # We will fail to send the notification because our registration\n        # would have failed\n        assert (\n            obj.notify(\n                title=\"test\", body=\"body\", notify_type=apprise.NotifyType.INFO\n            )\n            is False\n        )\n\n    # Now we test the growl.notify() function\n    mock_notifier.register.side_effect = None\n    for exception in TEST_GROWL_EXCEPTIONS:\n        mock_notifier.notify.side_effect = exception\n\n        # instantiate our object\n        obj = apprise.Apprise.instantiate(\n            \"growl://growl.server.hostname\", suppress_exceptions=False\n        )\n\n        # Verify Growl object was instantiated\n        assert obj is not None\n\n        # We will fail to send the notification because of the underlining\n        # notify() call throws an exception\n        assert (\n            obj.notify(\n                title=\"test\", body=\"body\", notify_type=apprise.NotifyType.INFO\n            )\n            is False\n        )\n\n\n@pytest.mark.skipif(\"gntp\" not in sys.modules, reason=\"Requires gntp\")\n@mock.patch(\"gntp.notifier.GrowlNotifier\")\ndef test_plugin_growl_general(mock_gntp):\n    \"\"\"NotifyGrowl() General Checks.\"\"\"\n\n    urls = (\n        ##################################\n        # NotifyGrowl\n        ##################################\n        (\n            \"growl://\",\n            {\n                \"instance\": None,\n            },\n        ),\n        (\"growl://:@/\", {\"instance\": None}),\n        (\n            \"growl://pass@growl.server\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://ignored:pass@growl.server\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://growl.server\",\n            {\n                \"instance\": NotifyGrowl,\n                # don't include an image by default\n                \"include_image\": False,\n            },\n        ),\n        (\n            \"growl://growl.server?version=1\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        # Test sticky flag\n        (\n            \"growl://growl.server?sticky=yes\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://growl.server?sticky=no\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        # Force a failure\n        (\n            \"growl://growl.server?version=1\",\n            {\n                \"instance\": NotifyGrowl,\n                \"growl_response\": None,\n            },\n        ),\n        (\n            \"growl://growl.server?version=2\",\n            {\n                # don't include an image by default\n                \"include_image\": False,\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://growl.server?version=2\",\n            {\n                # don't include an image by default\n                \"include_image\": False,\n                \"instance\": NotifyGrowl,\n                \"growl_response\": None,\n            },\n        ),\n        # Priorities\n        (\n            \"growl://pass@growl.server?priority=low\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://pass@growl.server?priority=moderate\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://pass@growl.server?priority=normal\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://pass@growl.server?priority=high\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://pass@growl.server?priority=emergency\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        # Invalid Priorities\n        (\n            \"growl://pass@growl.server?priority=invalid\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://pass@growl.server?priority=\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        # invalid version\n        (\n            \"growl://growl.server?version=\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://growl.server?version=crap\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        # Ports\n        (\n            \"growl://growl.changeport:2000\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://growl.garbageport:garbage\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n        (\n            \"growl://growl.colon:\",\n            {\n                \"instance\": NotifyGrowl,\n            },\n        ),\n    )\n\n    # iterate over our dictionary and test it out\n    for url, meta in urls:\n\n        # Our expected instance\n        instance = meta.get(\"instance\", None)\n\n        # Our expected exception\n        exception = meta.get(\"exception\", None)\n\n        # Our expected server objects\n        self = meta.get(\"self\", None)\n\n        # Our expected Query response (True, False, or exception type)\n        response = meta.get(\"response\", True)\n\n        # Allow us to force the server response code to be something other then\n        # the defaults\n        growl_response = meta.get(\"growl_response\", bool(response))\n\n        mock_notifier = mock.Mock()\n        mock_gntp.return_value = mock_notifier\n        mock_notifier.notify.side_effect = None\n\n        # Store our response\n        mock_notifier.notify.return_value = growl_response\n\n        try:\n            obj = apprise.Apprise.instantiate(url, suppress_exceptions=False)\n\n            assert exception is None\n\n            if obj is None:\n                # We're done\n                continue\n\n            if instance is None:\n                # Expected None but didn't get it\n                raise AssertionError()\n\n            assert isinstance(obj, instance) is True\n\n            # Test our URL Identifier is generated\n            assert isinstance(obj.url_id(), str) is True\n\n            if isinstance(obj, NotifyBase):\n                # We loaded okay; now lets make sure we can reverse this url\n                assert isinstance(obj.url(), str) is True\n\n                # Test our privacy=True flag\n                assert isinstance(obj.url(privacy=True), str) is True\n\n                # Instantiate the exact same object again using the URL from\n                # the one that was already created properly\n                obj_cmp = apprise.Apprise.instantiate(obj.url())\n\n                # Our object should be the same instance as what we had\n                # originally expected above.\n                if not isinstance(obj_cmp, NotifyBase):\n                    # Assert messages are hard to trace back with the way\n                    # these tests work. Just printing before throwing our\n                    # assertion failure makes things easier to debug later on\n                    raise AssertionError()\n\n            if self:\n                # Iterate over our expected entries inside of our object\n                for key, val in self.items():\n                    # Test that our object has the desired key\n                    assert hasattr(key, obj)\n                    assert getattr(key, obj) == val\n\n            try:\n                # check that we're as expected\n                assert (\n                    obj.notify(\n                        title=\"test\",\n                        body=\"body\",\n                        notify_type=apprise.NotifyType.INFO,\n                    )\n                    == response\n                )\n\n            except Exception as e:\n                # Check that we were expecting this exception to happen\n                assert isinstance(e, response)\n\n        except AssertionError:\n            # Don't mess with these entries\n            raise\n\n        except Exception as e:\n            # Handle our exception\n            assert exception is not None\n            assert isinstance(e, exception)\n\n\n@pytest.mark.skipif(\"gntp\" not in sys.modules, reason=\"Requires gntp\")\n@mock.patch(\"gntp.notifier.GrowlNotifier\")\ndef test_plugin_growl_config_files(mock_gntp):\n    \"\"\"NotifyGrowl() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - growl://pass@growl.server:\n          - priority: -2\n            tag: growl_int low\n          - priority: \"-2\"\n            tag: growl_str_int low\n          - priority: low\n            tag: growl_str low\n\n          # This will take on moderate (default) priority\n          - priority: invalid\n            tag: growl_invalid\n\n      - growl://pass@growl.server:\n          - priority: 2\n            tag: growl_int emerg\n          - priority: \"2\"\n            tag: growl_str_int emerg\n          - priority: emergency\n            tag: growl_str emerg\n    \"\"\"\n\n    mock_notifier = mock.Mock()\n    mock_gntp.return_value = mock_notifier\n    mock_notifier.notify.return_value = True\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 7 servers from that\n    # 3x low\n    # 3x emerg\n    # 1x invalid (so takes on normal priority)\n    assert len(ac.servers()) == 7\n    assert len(aobj) == 7\n    assert len(list(aobj.find(tag=\"low\"))) == 3\n    for s in aobj.find(tag=\"low\"):\n        assert s.priority == GrowlPriority.LOW\n\n    assert len(list(aobj.find(tag=\"emerg\"))) == 3\n    for s in aobj.find(tag=\"emerg\"):\n        assert s.priority == GrowlPriority.EMERGENCY\n\n    assert len(list(aobj.find(tag=\"growl_str\"))) == 2\n    assert len(list(aobj.find(tag=\"growl_str_int\"))) == 2\n    assert len(list(aobj.find(tag=\"growl_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"growl_invalid\"))) == 1\n    assert (\n        next(aobj.find(tag=\"growl_invalid\")).priority == GrowlPriority.NORMAL\n    )\n"
  },
  {
    "path": "tests/test_plugin_guilded.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.guilded import NotifyGuilded\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"guilded://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # An invalid url\n    (\n        \"guilded://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No webhook_token specified\n    (\n        \"guilded://%s\" % (\"i\" * 24),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Provide both an webhook id and a webhook token\n    (\n        \"guilded://{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Provide a temporary username\n    (\n        \"guilded://l2g@{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # test image= field\n    (\n        \"guilded://{}/{}?format=markdown&footer=Yes&image=Yes\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"guilded://{}/{}?format=markdown&footer=Yes&image=No&fields=no\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"guilded://{}/{}?format=markdown&footer=Yes&image=Yes\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"https://media.guilded.gg/webhooks/{}/{}\".format(\"0\" * 10, \"B\" * 40),\n        {\n            # Native URL Support, support the provided guilded URL from their\n            # webpage.\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"guilded://{}/{}?format=markdown&avatar=No&footer=No\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # different format support\n    (\n        \"guilded://{}/{}?format=markdown\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    (\n        \"guilded://{}/{}?format=text\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test with avatar URL\n    (\n        \"guilded://{}/{}?avatar_url=http://localhost/test.jpg\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n        },\n    ),\n    # Test without image set\n    (\n        \"guilded://{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            \"requests_response_code\": requests.codes.no_content,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"guilded://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"guilded://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"guilded://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyGuilded,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_guilded_urls():\n    \"\"\"NotifyGuilded() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_guilded_general(mock_post):\n    \"\"\"NotifyGuilded() General Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    webhook_id = \"A\" * 24\n    webhook_token = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Invalid webhook id\n    with pytest.raises(TypeError):\n        NotifyGuilded(webhook_id=None, webhook_token=webhook_token)\n    # Invalid webhook id (whitespace)\n    with pytest.raises(TypeError):\n        NotifyGuilded(webhook_id=\"  \", webhook_token=webhook_token)\n\n    # Invalid webhook token\n    with pytest.raises(TypeError):\n        NotifyGuilded(webhook_id=webhook_id, webhook_token=None)\n    # Invalid webhook token (whitespace)\n    with pytest.raises(TypeError):\n        NotifyGuilded(webhook_id=webhook_id, webhook_token=\"   \")\n\n    obj = NotifyGuilded(\n        webhook_id=webhook_id,\n        webhook_token=webhook_token,\n        footer=True,\n        thumbnail=False,\n    )\n\n    # Test that we get a string response\n    assert isinstance(obj.url(), str)\n"
  },
  {
    "path": "tests/test_plugin_homeassistant.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.home_assistant import NotifyHomeAssistant\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"hassio://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"hassio://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"hassios://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No Long Lived Access Token specified\n    (\n        \"hassio://user@localhost\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"hassio://localhost/long-lived-access-token\",\n        {\n            \"instance\": NotifyHomeAssistant,\n        },\n    ),\n    (\n        \"hassio://user:pass@localhost/long-lived-access-token/\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"hassio://user:****@localhost/l...n\",\n        },\n    ),\n    (\n        \"hassio://localhost:80/long-lived-access-token\",\n        {\n            \"instance\": NotifyHomeAssistant,\n        },\n    ),\n    (\n        \"hassio://user@localhost:8123/llat\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            \"privacy_url\": \"hassio://user@localhost/l...t\",\n        },\n    ),\n    (\n        \"hassios://localhost/llat?nid=!%\",\n        {\n            # Invalid notification_id\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"hassios://localhost/llat?nid=abcd\",\n        {\n            # Valid notification_id\n            \"instance\": NotifyHomeAssistant,\n        },\n    ),\n    (\n        \"hassios://user:pass@localhost/llat\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            \"privacy_url\": \"hassios://user:****@localhost/l...t\",\n        },\n    ),\n    (\n        \"hassios://localhost:8443/path/llat/\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            \"privacy_url\": \"hassios://localhost:8443/path/l...t\",\n        },\n    ),\n    (\n        \"hassio://localhost:8123/a/path?accesstoken=llat\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            # Default port; so it's stripped off\n            # accesstoken was specified as kwarg\n            \"privacy_url\": \"hassio://localhost/a/path/l...t\",\n        },\n    ),\n    (\n        \"hassios://user:password@localhost:80/llat/\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            \"privacy_url\": \"hassios://user:****@localhost:80\",\n        },\n    ),\n    (\n        \"hassio://user:pass@localhost:8123/llat\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"hassio://user:pass@localhost/llat\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"hassio://user:pass@localhost/llat\",\n        {\n            \"instance\": NotifyHomeAssistant,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_homeassistant_urls():\n    \"\"\"NotifyHomeAssistant() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_homeassistant_general(mock_post):\n    \"\"\"NotifyHomeAssistant() General Checks.\"\"\"\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Variation Initializations\n    obj = Apprise.instantiate(\"hassio://localhost/accesstoken\")\n    assert isinstance(obj, NotifyHomeAssistant) is True\n    assert isinstance(obj.url(), str) is True\n\n    # Send Notification\n    assert obj.send(body=\"test\") is True\n\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"http://localhost:8123/api/services/persistent_notification/create\"\n    )\n"
  },
  {
    "path": "tests/test_plugin_httpsms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.httpsms import NotifyHttpSMS\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"httpsms://\",\n        {\n            # Instantiated but no auth, so no notification can happen\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"httpsms://:@/\",\n        {\n            # invalid token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"httpsms://{}:{}@{}\".format(\"u\" * 10, \"p\" * 10, \"3\" * 5),\n        {\n            # invalid source number provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"httpsms://{}@{}/{}\".format(\"p\" * 10, \"1\" * 10, 2 * \"5\"),\n        {\n            # invalid target number provided\n            \"instance\": NotifyHttpSMS,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"httpsms://{}@{}\".format(\"p\" * 10, \"2\" * 10),\n        {\n            # default to ourselves\n            \"instance\": NotifyHttpSMS,\n        },\n    ),\n    (\n        \"httpsms://{}@9876543210/{}/abcd/\".format(\"b\" * 10, \"3\" * 11),\n        {\n            # included phone, short number (123) and garbage string (abcd)\n            # dropped\n            \"instance\": NotifyHttpSMS,\n            \"privacy_url\": \"httpsms://b...b@9876543210/33333333333\",\n        },\n    ),\n    (\n        \"httpsms://{}@{}\".format(\"c\" * 10, \"4\" * 11),\n        {\n            \"instance\": NotifyHttpSMS,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"httpsms://c...c@44444444444\",\n        },\n    ),\n    (\n        \"httpsms://{}@{}\".format(\"b\" * 10, \"5\" * 11),\n        {\n            # using phone no with no target - we text ourselves in this case\n            \"instance\": NotifyHttpSMS,\n        },\n    ),\n    (\n        \"httpsms://?key={}&from={}\".format(\"y\" * 10, \"5\" * 11),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyHttpSMS,\n        },\n    ),\n    (\n        \"httpsms://?key={}&from={}&to={}\".format(\"b\" * 10, \"5\" * 11, \"7\" * 13),\n        {\n            # use to= and key=\n            \"instance\": NotifyHttpSMS,\n        },\n    ),\n    (\n        \"httpsms://{}@{}\".format(\"b\" * 10, \"5\" * 11),\n        {\n            \"instance\": NotifyHttpSMS,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"httpsms://{}@{}\".format(\"b\" * 10, \"5\" * 11),\n        {\n            \"instance\": NotifyHttpSMS,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_httpsms_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_httpsms_edge_cases(mock_post):\n    \"\"\"NotifyHttpSMS() Edge Cases.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    apikey = \"mykey123\"\n    source = \"1 (405) 123 1234\"\n    targets = [\n        \"+1(555) 123-1234\",\n        \"1555 5555555\",\n        # A garbage entry\n        \"12\",\n        # NOw a valid one because a group was implicit\n        \"@12\",\n    ]\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"httpsms://{}@{}/{}\".format(apikey, source, \"/\".join(targets))\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # We know there are 2 targets\n    assert len(obj) == 2\n\n    # Test our call count\n    assert mock_post.call_count == 2\n\n    # Test\n    details = mock_post.call_args_list[0]\n    payload = loads(details[1][\"data\"])\n    assert payload[\"from\"] == \"+14051231234\"\n    assert payload[\"to\"] == \"+15551231234\"\n    assert payload[\"content\"] == \"title\\r\\nbody\"\n\n    details = mock_post.call_args_list[1]\n    payload = loads(details[1][\"data\"])\n    assert payload[\"from\"] == \"+14051231234\"\n    assert payload[\"to\"] == \"+15555555555\"\n    assert payload[\"content\"] == \"title\\r\\nbody\"\n\n    # Verify our URL looks good\n    assert obj.url().startswith(\n        \"httpsms://mykey123@14051231234/15551231234/15555555555\"\n    )\n"
  },
  {
    "path": "tests/test_plugin_ifttt.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import NotifyType\nfrom apprise.plugins.ifttt import NotifyIFTTT\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"ifttt://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ifttt://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No User\n    (\n        \"ifttt://EventID/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # A nicely formed ifttt url with 1 event and a new key/value store\n    (\n        \"ifttt://WebHookID@EventID/?+TemplateKey=TemplateVal\",\n        {\n            \"instance\": NotifyIFTTT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ifttt://W...D\",\n        },\n    ),\n    # Test to= in which case we set the host to the webhook id\n    (\n        \"ifttt://WebHookID?to=EventID,EventID2\",\n        {\n            \"instance\": NotifyIFTTT,\n        },\n    ),\n    # Removing certain keys:\n    (\n        \"ifttt://WebHookID@EventID/?-Value1=&-Value2\",\n        {\n            \"instance\": NotifyIFTTT,\n        },\n    ),\n    # A nicely formed ifttt url with 2 events defined:\n    (\n        \"ifttt://WebHookID@EventID/EventID2/\",\n        {\n            \"instance\": NotifyIFTTT,\n        },\n    ),\n    # Support Native URL references\n    (\n        \"https://maker.ifttt.com/use/WebHookID/\",\n        {\n            # No EventID specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"https://maker.ifttt.com/use/WebHookID/EventID/\",\n        {\n            \"instance\": NotifyIFTTT,\n        },\n    ),\n    #  Native URL with arguments\n    (\n        \"https://maker.ifttt.com/use/WebHookID/EventID/?-Value1=\",\n        {\n            \"instance\": NotifyIFTTT,\n        },\n    ),\n    # Test website connection failures\n    (\n        \"ifttt://WebHookID@EventID\",\n        {\n            \"instance\": NotifyIFTTT,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"ifttt://WebHookID@EventID\",\n        {\n            \"instance\": NotifyIFTTT,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"ifttt://WebHookID@EventID\",\n        {\n            \"instance\": NotifyIFTTT,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_ifttt_urls():\n    \"\"\"NotifyIFTTT() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_ifttt_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyIFTTT() Edge Cases.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    webhook_id = \"webhook_id\"\n    events = [\"event1\", \"event2\"]\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_get.return_value.content = \"{}\"\n    mock_post.return_value.content = \"{}\"\n\n    # No webhook_id specified\n    with pytest.raises(TypeError):\n        NotifyIFTTT(webhook_id=None, events=None)\n\n    # Initializes the plugin with an invalid webhook id\n    with pytest.raises(TypeError):\n        NotifyIFTTT(webhook_id=None, events=events)\n\n    # Whitespace also acts as an invalid webhook id\n    with pytest.raises(TypeError):\n        NotifyIFTTT(webhook_id=\"   \", events=events)\n\n    # No events specified\n    with pytest.raises(TypeError):\n        NotifyIFTTT(webhook_id=webhook_id, events=None)\n\n    obj = NotifyIFTTT(webhook_id=webhook_id, events=events)\n    assert isinstance(obj, NotifyIFTTT)\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Validate outbound payload does not leak NotifyType Enum\n    assert mock_post.call_count >= 1\n    call = mock_post.call_args_list[-1]\n    payload = call[1].get(\"json\") or call[1].get(\"data\") or \"\"\n    payload_text = payload if isinstance(payload, str) else str(payload)\n\n    assert \"NotifyType.\" not in payload_text\n    # If JSON dict is used:\n    if isinstance(payload, dict):\n        assert NotifyType.INFO.value in payload_text\n\n    # Test the addition of tokens\n    obj = NotifyIFTTT(\n        webhook_id=webhook_id,\n        events=events,\n        add_tokens={\"Test\": \"ValueA\", \"Test2\": \"ValueB\"},\n    )\n\n    assert isinstance(obj, NotifyIFTTT)\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Invalid del_tokens entry\n    with pytest.raises(TypeError):\n        NotifyIFTTT(\n            webhook_id=webhook_id,\n            events=events,\n            del_tokens=NotifyIFTTT.ifttt_default_title_key,\n        )\n\n    assert isinstance(obj, NotifyIFTTT)\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Test removal of tokens by a list\n    obj = NotifyIFTTT(\n        webhook_id=webhook_id,\n        events=events,\n        add_tokens={\"MyKey\": \"MyValue\"},\n        del_tokens=(\n            NotifyIFTTT.ifttt_default_title_key,\n            NotifyIFTTT.ifttt_default_body_key,\n            NotifyIFTTT.ifttt_default_type_key,\n        ),\n    )\n\n    assert isinstance(obj, NotifyIFTTT)\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Test removal of tokens as dict\n    obj = NotifyIFTTT(\n        webhook_id=webhook_id,\n        events=events,\n        add_tokens={\"MyKey\": \"MyValue\"},\n        del_tokens={\n            NotifyIFTTT.ifttt_default_title_key: None,\n            NotifyIFTTT.ifttt_default_body_key: None,\n            NotifyIFTTT.ifttt_default_type_key: None,\n        },\n    )\n\n    assert isinstance(obj, NotifyIFTTT)\n"
  },
  {
    "path": "tests/test_plugin_irc.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"Unit tests for Apprise IRC plugin and helper modules.\"\"\"\n\nfrom __future__ import annotations\n\nimport logging\nimport time\nfrom typing import Any, Optional\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise.plugins.irc import NotifyIRC\nfrom apprise.plugins.irc.client import IRCClient\nfrom apprise.plugins.irc.protocol import (\n    IRCAuthMode,\n    IRCMessage,\n    extract_welcome_nick,\n    is_ping,\n    normalise_channel,\n    parse_irc_line,\n    ping_payload,\n)\nfrom apprise.plugins.irc.state import (\n    IRCActionKind,\n    IRCContext,\n    IRCState,\n    IRCStateMachine,\n)\nfrom apprise.utils.socket import AppriseSocketError\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n\nclass _DummyTransport:\n    \"\"\"A lightweight SocketTransport stand-in for IRCClient tests.\"\"\"\n\n    def __init__(self) -> None:\n        self.connected = False\n        self.closed = False\n        self.writes: list[bytes] = []\n        self.reads: list[bytes] = []\n\n    def connect(self) -> None:\n        self.connected = True\n\n    def close(self) -> None:\n        self.closed = True\n\n    def write(self, payload: bytes, *, flush: bool, timeout: float) -> None:\n        self.writes.append(payload)\n\n    def read(\n        self,\n        max_bytes: int,\n        *,\n        blocking: bool,\n        timeout: Optional[float],\n    ) -> bytes:\n        if not self.reads:\n            return b\"\"\n        chunk = self.reads.pop(0)\n        return chunk[:max_bytes]\n\n\ndef test_plugin_irc_init_targets() -> None:\n    \"\"\"NotifyIRC parses targets.\"\"\"\n    with mock.patch.object(NotifyIRC, \"apply_irc_defaults\"):\n        n = NotifyIRC(\n            host=\"irc.example.com\",\n            targets=[\"#chan\", \"%23chan2:key\", \"@bob\", \"%40alice\", \"  bad# \"],\n        )\n\n    assert \"chan\" in n.channels\n    assert n.channels[\"chan\"] is None\n    assert \"chan2\" in n.channels\n    assert n.channels[\"chan2\"] == \"key\"\n    assert \"bob\" in n.users\n    assert \"alice\" in n.users\n\n\ndef test_plugin_irc_modes() -> None:\n    \"\"\"NotifyIRC auth mode tests.\"\"\"\n    with (\n            mock.patch.object(NotifyIRC, \"apply_irc_defaults\"),\n            pytest.raises(TypeError)):\n        NotifyIRC(host=\"irc.example.com\", targets=[\"#c\"], mode=\"invalid\")\n\n    with mock.patch.object(NotifyIRC, \"apply_irc_defaults\"):\n        # Case Insensitive\n        result = NotifyIRC(\n            host=\"irc.example.com\", targets=[\"#c\"], mode=\"NICKServ\")\n        assert \"mode=nickserv\" in result.url()\n\n        # Case Insensitive\n        result = NotifyIRC(\n            host=\"irc.example.com\", targets=[\"#c\"], mode=\"server\")\n        assert \"mode=\" not in result.url()\n\n\ndef test_plugin_irc_defaults_port_noop() -> None:\n    \"\"\"NotifyIRC defaults are not applied when port is explicit.\"\"\"\n    n = NotifyIRC(host=\"irc.efnet.org\", targets=[\"#c\"], port=7000)\n    n.apply_irc_defaults()\n    assert n.port == 7000\n\n\ndef test_plugin_irc_defaults_template_match() -> None:\n    \"\"\"NotifyIRC defaults apply for known networks.\"\"\"\n    n = NotifyIRC(host=\"irc.synirc.net\", targets=[\"#c\"])\n    assert n.secure is True\n    assert n.port == 6697\n    assert n.auth_mode == IRCAuthMode.NICKSERV\n\n\ndef test_plugin_irc_defaults_template_none() -> None:\n    \"\"\"NotifyIRC defaults do not force unknown networks.\"\"\"\n    n = NotifyIRC(host=\"irc.unknown.tld\", targets=[\"#c\"])\n    assert n.secure is False\n\n\ndef test_plugin_irc_send_no_targets() -> None:\n    \"\"\"NotifyIRC send() rejects empty targets.\"\"\"\n    n = NotifyIRC(host=\"irc.example.com\", targets=[])\n    assert n.send(\"body\") is False\n\n\ndef test_plugin_irc_send_ok() -> None:\n    \"\"\"NotifyIRC send() valid path.\"\"\"\n    n = NotifyIRC(\n        host=\"irc.example.com\",\n        targets=[\"#chan:key\", \"@bob\"],\n        user=\"me\",\n        password=\"pw\",\n    )\n    n.join = True\n\n    client = mock.Mock(spec=IRCClient)\n    client.nickname = \"me\"\n\n    with mock.patch(\"apprise.plugins.irc.base.IRCClient\", return_value=client):\n        assert n.send(\"body\", title=\"title\") is True\n\n    client.connect.assert_called_once()\n    client.register.assert_called_once()\n    client.join.assert_called_once()\n    client.privmsg.assert_any_call(\n        target=\"#chan\",\n        message=\"title body\",\n        timeout=mock.ANY,\n    )\n    client.privmsg.assert_any_call(\n        target=\"bob\",\n        message=\"title body\",\n        timeout=mock.ANY,\n    )\n    client.quit.assert_called_once()\n    client.close.assert_called_once()\n\n\ndef test_plugin_irc_send_no_join() -> None:\n    \"\"\"NotifyIRC avoids JOIN when join is false and no key is provided.\"\"\"\n    n = NotifyIRC(host=\"irc.example.com\", targets=[\"#chan\", \"@bob\"])\n    n.join = False\n\n    client = mock.Mock(spec=IRCClient)\n    client.nickname = \"x\"\n\n    with mock.patch(\"apprise.plugins.irc.base.IRCClient\", return_value=client):\n        assert n.send(\"body\") is True\n\n    client.join.assert_not_called()\n    client.privmsg.assert_any_call(\n        target=\"#chan\",\n        message=\"body\",\n        timeout=mock.ANY,\n    )\n\n\ndef test_plugin_irc_send_error() -> None:\n    \"\"\"NotifyIRC returns False on connect errors.\"\"\"\n    n = NotifyIRC(host=\"irc.example.com\", targets=[\"#chan\"])\n    client = mock.Mock(spec=IRCClient)\n    client.connect.side_effect = AppriseSocketError(\"boom\")\n\n    with mock.patch(\"apprise.plugins.irc.base.IRCClient\", return_value=client):\n        assert n.send(\"body\") is False\n\n    client.close.assert_called_once()\n\n\ndef test_plugin_irc_url_id() -> None:\n    \"\"\"NotifyIRC url_identifier.\"\"\"\n    n = NotifyIRC(\n        host=\"irc.example.com\", targets=[\"#c\"], user=\"me\", password=\"pw\")\n    assert n.url_identifier == (\"irc\", \"irc.example.com\", \"me\", \"pw\")\n\n    n.secure = True\n    assert n.url_identifier[0] == \"ircs\"\n\n\ndef test_plugin_irc_url_format() -> None:\n    \"\"\"NotifyIRC url() basic rendering and privacy.\"\"\"\n    n = NotifyIRC(\n        host=\"irc.example.com\",\n        targets=[\"#chan:key\", \"@bob\"],\n        user=\"me\",\n        password=\"pw\",\n        secure=False,\n        port=IRCClient.default_insecure_port,\n        nick=\"nick\",\n        name=\"Real Name\",\n        join=False,\n        mode=IRCAuthMode.NICKSERV,\n    )\n\n    url = n.url(privacy=False)\n    assert url.startswith(\"irc://me:pw@irc.example.com/\")\n    assert \"#chan\" in url\n    assert \"@bob\" in url\n\n    # Mode only appears when auth_mode is not SERVER.\n    if n.auth_mode != IRCAuthMode.SERVER:\n        assert \"mode=nickserv\" in url\n    else:\n        assert \"mode=\" not in url\n\n    private = n.url(privacy=True)\n    assert \"pw\" not in private\n    assert \"key\" not in private\n\n    n = NotifyIRC(\n        host=\"irc.example.com\",\n        targets=[\"#chan\", \"@bob\"],\n        user=\"user2\",\n        secure=True,\n        port=IRCClient.default_insecure_port,\n        join=False,\n        mode=IRCAuthMode.NICKSERV,\n    )\n\n    url = n.url(privacy=False)\n    assert url.startswith(\"ircs://user2@irc.example.com:6667/\")\n    assert \"#chan\" in url\n    assert \"@bob\" in url\n\n    # Mode only appears when auth_mode is not SERVER.\n    if n.auth_mode != IRCAuthMode.SERVER:\n        assert \"mode=nickserv\" in url\n    else:\n        assert \"mode=\" not in url\n\n    private = n.url(privacy=True)\n    assert \"pw\" not in private\n    assert \"key\" not in private\n\n\ndef test_plugin_irc_parse_url() -> None:\n    \"\"\"NotifyIRC parse_url() behaviour with host= query.\"\"\"\n\n    with mock.patch(\n        \"apprise.plugins.irc.base.NotifyBase.parse_url\",\n        return_value=None,\n    ):\n        results = NotifyIRC.parse_url(\"irc://%@@\")\n        assert results is None\n\n    # host= indicates the URL host is a target, it does not override host.\n    results = NotifyIRC.parse_url(\"irc://%23chan?host=irc.example.com\")\n    assert results is not None\n    assert results[\"host\"] == \"%23chan\"\n    assert \"#chan\" in results[\"targets\"]\n\n    results = NotifyIRC.parse_url(\n        \"irc://irc.example.com/%23a/@b?to=%23c,@d\"\n        \"&join=no&name=Z&nick=N&mode=none\"\n    )\n    assert results is not None\n    assert results[\"host\"] == \"irc.example.com\"\n    assert \"#a\" in results[\"targets\"]\n    assert \"@b\" in results[\"targets\"]\n    assert \"#c\" in results[\"targets\"]\n    assert \"@d\" in results[\"targets\"]\n    assert results[\"join\"] is False\n    assert results[\"name\"] == \"Z\"\n    assert results[\"nick\"] == \"N\"\n    assert results[\"mode\"] == \"none\"\n\n\ndef test_plugin_irc_protocol() -> None:\n    \"\"\"Protocol helpers.\"\"\"\n    msg = parse_irc_line(\":srv 001 nick :welcome\")\n    assert msg.numeric == 1\n    assert extract_welcome_nick(msg) == \"nick\"\n\n    ping = parse_irc_line(\"PING :abc\")\n    assert is_ping(ping) is True\n    assert ping_payload(ping) == \"abc\"\n\n    assert normalise_channel(\"chan\") == \"#chan\"\n    assert normalise_channel(\"#chan\") == \"#chan\"\n    assert normalise_channel(\"    \") == \"\"\n\n\ndef test_plugin_irc_state_machine() -> None:\n    \"\"\"State machine basics.\"\"\"\n    ctx = IRCContext(\n        desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\", password=\"pw\")\n    sm = IRCStateMachine(ctx)\n\n    acts = sm.start_registration()\n    assert any(\n        a.kind == IRCActionKind.SEND and a.line and a.line.startswith(\"PASS \")\n        for a in acts\n    )\n\n    sm.on_message(parse_irc_line(\":srv 001 nick :welcome\"))\n    assert sm.ctx.registered is True\n    assert sm.ctx.accepted_nick == \"nick\"\n\n    ctx2 = IRCContext(\n        desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\", password=\"pw\")\n    sm2 = IRCStateMachine(ctx2)\n    sm2.start_registration()\n    fail = sm2.on_message(parse_irc_line(\":srv 464 n :bad pass\"))\n    assert fail and fail[0].kind == IRCActionKind.FAIL\n\n    ctx3 = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm3 = IRCStateMachine(ctx3)\n    sm3.start_registration()\n    sm3.on_message(parse_irc_line(\":srv 001 n :welcome\"))\n    sm3.request_join(\"#c\", key=None)\n    sm3.on_message(parse_irc_line(\":srv 366 n #c :End of /NAMES list.\"))\n    assert \"#c\" in sm3.ctx.joined\n\n\ndef test_plugin_irc_client_nick_handling() -> None:\n    \"\"\"IRCClient nickname handling.\"\"\"\n    nick0 = IRCClient.nick_generation(prefix=\"Apprise\", length=9, collision=0)\n    assert len(nick0) == 9\n\n    nick1 = IRCClient.nick_generation(prefix=\"Apprise\", length=9, collision=12)\n    assert len(nick1) == 9\n    assert nick1[-1].isdigit()\n\n    client = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        nick_generator=None,\n    )\n    with pytest.raises(AppriseSocketError):\n        client._nickname_collision_handler(prefix=\"x\")\n\n    def gen(prefix: str, length: int, collision: int) -> str:\n        return f\"{prefix}{collision}\"\n\n    client = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        nick_generator=gen,\n    )\n    client._nick_collision = int(client.nickname_collision_max)\n    with pytest.raises(AppriseSocketError):\n        client._nickname_collision_handler(prefix=\"x\")\n\n\ndef test_plugin_irc_client_handshake() -> None:\n    \"\"\"IRCClient handshake error path.\"\"\"\n    def gen(prefix: str, length: int, collision: int) -> str:\n        return \"newnick\"\n\n    client = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        nick_generator=gen,\n    )\n    client.transport = _DummyTransport()\n\n    # Put the state machine into REGISTERING so 464 triggers FAIL.\n    client.sm.start_registration()\n\n    lines = [\n        \"PING :abc\",\n        \":srv 433 nick :in use\",\n        \":srv 464 nick :bad pass\",\n        \"\",\n    ]\n\n    def _read(deadline: float) -> Optional[str]:\n        return lines.pop(0)\n\n    writes: list[str] = []\n\n    def _write(line: Any, deadline: float) -> None:\n        if isinstance(line, bytes):\n            line = line.decode(\"utf-8\", errors=\"replace\")\n        writes.append(str(line))\n\n    with (\n            mock.patch.object(client, \"_read\", side_effect=_read),\n            mock.patch.object(client, \"_write\", side_effect=_write),\n            pytest.raises(AppriseSocketError)):\n        client._handshake(deadline=time.monotonic() + 1.0, prefix=\"ap\")\n\n    assert any(w.startswith(\"PONG\") for w in writes)\n    assert any(w.startswith(\"NICK \") for w in writes)\n\n\ndef test_plugin_irc_client_register() -> None:\n    \"\"\"IRCClient register timeout and success.\"\"\"\n    client = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.NICKSERV,\n    )\n    client.transport = _DummyTransport()\n\n    with (mock.patch(\"time.monotonic\", side_effect=[0.0, 1.0]),\n          pytest.raises(TimeoutError)):\n        client.register(timeout=0.01, prefix=\"ap\")\n\n    client2 = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.NICKSERV,\n    )\n    client2.transport = _DummyTransport()\n\n    def _handshake(deadline: float, prefix: str) -> None:\n        client2.sm.ctx.registered = True\n\n    with (\n        mock.patch.object(client2, \"_flush\"),\n        mock.patch.object(client2, \"_handshake\", side_effect=_handshake),\n        mock.patch.object(client2, \"identify\") as m_identify,\n    ):\n        client2.register(timeout=0.1, prefix=\"ap\")\n        m_identify.assert_called_once()\n\n\ndef test_plugin_irc_client_identify() -> None:\n    \"\"\"IRCClient identify early exits and send.\"\"\"\n    client = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        password=None,\n        auth_mode=IRCAuthMode.NICKSERV,\n    )\n    client.transport = _DummyTransport()\n    client.identify(timeout=0.1)\n\n    client2 = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.SERVER,\n    )\n    client2.transport = _DummyTransport()\n    client2.identify(timeout=0.1)\n\n    client3 = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.NICKSERV,\n    )\n    client3.transport = _DummyTransport()\n    with (\n        mock.patch.object(client3, \"_flush\") as m_flush,\n        mock.patch.object(client3, \"_handshake\") as m_hs,\n    ):\n        client3.identify(timeout=0.1)\n        m_flush.assert_called_once()\n        m_hs.assert_called_once()\n\n\ndef test_plugin_irc_client_join() -> None:\n    \"\"\"IRCClient join timeout path.\"\"\"\n    client = IRCClient(\n        host=\"irc.example.com\",\n        nickname=\"nick\",\n        fullname=\"full\",\n    )\n    client.transport = _DummyTransport()\n\n    # join() calls monotonic frequently; use a callable so we never exhaust\n    # a finite side_effect list.\n    calls = {\"n\": 0}\n\n    def _mono() -> float:\n        calls[\"n\"] += 1\n        # Stay below deadline long enough to exercise the loop, then exceed it.\n        return 0.0 if calls[\"n\"] < 2000 else 1.0\n\n    with (\n        mock.patch.object(client, \"_flush\") as m_flush,\n        mock.patch.object(client, \"_handshake\") as m_hs,\n        mock.patch(\"time.monotonic\", side_effect=_mono),\n    ):\n        client.join(channel=\"#chan\", timeout=0.01, prefix=\"ap\", key=None)\n        assert m_flush.called\n        assert m_hs.called\n\n    with mock.patch.object(client, \"_write\") as m_write:\n        client.quit(message=\"bye\", timeout=0.1)\n        assert m_write.called\n\n\ndef test_plugin_irc_protocol_parse_blank_line() -> None:\n    \"\"\"Protocol parse blank input.\"\"\"\n    msg = parse_irc_line(\"   \")\n    assert msg.command == \"\"\n    assert msg.params == ()\n    assert msg.trailing is None\n\n\ndef test_plugin_irc_protocol_parse_trailing_only() -> None:\n    \"\"\"Protocol parse trailing only.\"\"\"\n    msg = parse_irc_line(\" :hello\")\n    assert msg.command == \"\"\n    assert msg.params == ()\n    assert msg.trailing == \"hello\"\n\n\ndef test_plugin_irc_message() -> None:\n    \"\"\"IRCMessage testing\"\"\"\n\n    message = IRCMessage(\"raw\", None, \"not-a-number\", (\"a\", \"b\"), None)\n    assert message.numeric is None\n\n\ndef test_plugin_irc_protocol_ping_payload_from_params() -> None:\n    \"\"\"Ping payload from params.\"\"\"\n    msg = parse_irc_line(\"PING abc\")\n    assert msg.trailing is None\n    assert msg.params == (\"abc\",)\n    assert ping_payload(msg) == \"abc\"\n\n\ndef test_plugin_irc_protocol_extract_welcome_nick_non_welcome() -> None:\n    \"\"\"Welcome nick ignores non-001.\"\"\"\n    msg = parse_irc_line(\":srv 002 nick :Your host is\")\n    assert msg.numeric == 2\n    assert extract_welcome_nick(msg) is None\n\n\ndef test_plugin_irc_protocol_normalise_channel_empty() -> None:\n    \"\"\"Normalise empty channel.\"\"\"\n    assert normalise_channel(\"\") == \"\"\n\n\ndef test_plugin_irc_client_props_and_io() -> None:\n    \"\"\"Client basics.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n    c.sm.ctx.accepted_nick = \"nick\"\n    assert c.nickname == \"nick\"\n    c.connect()\n    c.close()\n    c.transport.connect.assert_called_once()\n    c.transport.close.assert_called_once()\n\n\ndef test_plugin_irc_client_write_timeout() -> None:\n    \"\"\"Write timeout.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n    with (mock.patch(\"time.monotonic\", return_value=10.0),\n          pytest.raises(TimeoutError)):\n        c._write(\"X\", deadline=9.0)\n\n\ndef test_plugin_irc_client_write_bytes_and_flush() -> None:\n    \"\"\"Write bytes and flush queue.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    with mock.patch(\"time.monotonic\", return_value=0.0):\n        c._write(b\"RAW\", deadline=1.0)\n        c.transport.write.assert_called_with(b\"RAW\", flush=True, timeout=1.0)\n\n    c.transport.write.reset_mock()\n    c._queue(\"A\")\n    c._queue(\"B\")\n    with mock.patch.object(c, \"_write\") as m_write:\n        c._flush(deadline=1.0)\n        assert m_write.call_count == 2\n        assert len(c._out_queue) == 0\n\n\ndef test_plugin_irc_client_read_buffer_and_timeout() -> None:\n    \"\"\"Read buffer and timeout.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    c._inbuf = bytearray(b\"hello\\r\\n\")\n    assert c._read(deadline=time.monotonic() + 1.0) == \"hello\"\n\n    c._inbuf = bytearray()\n    with mock.patch(\"time.monotonic\", return_value=10.0):\n        assert c._read(deadline=9.0) is None\n\n\ndef test_plugin_irc_client_read_transport_paths() -> None:\n    \"\"\"Read from transport.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    with mock.patch(\"time.monotonic\", return_value=0.0):\n        c.transport.read.return_value = b\"\"\n        assert c._read(deadline=1.0) is None\n\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n    with mock.patch(\"time.monotonic\", side_effect=[0.0, 0.0, 0.0]):\n        c.transport.read.side_effect = [b\"he\", b\"llo\\n\"]\n        assert c._read(deadline=1.0) == \"hello\"\n\n\ndef test_plugin_irc_client_tick() -> None:\n    \"\"\"Tick timing.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    with mock.patch(\"time.monotonic\", return_value=10.0):\n        assert c._tick(deadline=9.0) == pytest.approx(9.0)\n\n    with mock.patch(\"time.monotonic\", return_value=0.0):\n        # remaining=0.5 -> 0.0 + 0.5\n        assert c._tick(deadline=0.5) == pytest.approx(0.5)\n\n\ndef test_plugin_irc_client_handshake_paths() -> None:\n    \"\"\"Handshake paths.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    with mock.patch.object(c, \"_read\", return_value=None):\n        c._handshake(deadline=time.monotonic() + 1.0, prefix=\"\")\n\n    lines = [\"PING :abc\", \"\"]\n    with (\n        mock.patch.object(\n            c, \"_read\", side_effect=lambda deadline: lines.pop(0)),\n        mock.patch.object(c, \"_write\") as m_write,\n    ):\n        c._handshake(deadline=time.monotonic() + 1.0, prefix=\"\")\n        m_write.assert_any_call(\"PONG :abc\", deadline=mock.ANY)\n\n    lines = [\":srv 433 n :in use\", \"\"]\n    with (\n        mock.patch.object(\n            c, \"_read\", side_effect=lambda deadline: lines.pop(0)),\n        mock.patch.object(\n            c, \"_nickname_collision_handler\", return_value=\"new\"),\n        mock.patch.object(c, \"_write\") as m_write,\n    ):\n        c._handshake(deadline=time.monotonic() + 1.0, prefix=\"ap\")\n        m_write.assert_any_call(\"NICK new\", deadline=mock.ANY)\n\n    # FAIL action raises\n    from apprise.plugins.irc.state import IRCAction, IRCActionKind\n    lines = [\":srv 464 n :bad pass\", \"\"]\n    with (\n        mock.patch.object(\n            c, \"_read\", side_effect=lambda deadline: lines.pop(0)),\n        mock.patch.object(c.sm, \"on_message\", return_value=[\n            IRCAction(IRCActionKind.FAIL, reason=\"boom\")\n        ]),\n        pytest.raises(AppriseSocketError),\n    ):\n        c._handshake(deadline=time.monotonic() + 1.0, prefix=\"\")\n\n    # SEND action writes line\n    lines = [\":srv 001 n :welcome\", \"\"]\n    with (\n        mock.patch.object(\n            c, \"_read\", side_effect=lambda deadline: lines.pop(0)),\n        mock.patch.object(c.sm, \"on_message\", return_value=[\n            IRCAction(IRCActionKind.SEND, line=\"NICK x\")\n        ]),\n        mock.patch.object(c, \"_write\") as m_write,\n    ):\n        c._handshake(deadline=time.monotonic() + 1.0, prefix=\"\")\n        m_write.assert_any_call(\"NICK x\", deadline=mock.ANY)\n\n\ndef test_plugin_irc_client_register_auth_modes() -> None:\n    \"\"\"Register auth modes.\"\"\"\n    c = IRCClient(\n        host=\"h\",\n        nickname=\"n\",\n        fullname=\"f\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.NICKSERV,\n    )\n    c.transport = mock.Mock()\n\n    # Force loop to exit by setting registered in handshake\n    def _hs(deadline: float, prefix: str) -> None:\n        c.sm.ctx.registered = True\n\n    with (\n        mock.patch.object(c, \"_flush\"),\n        mock.patch.object(c, \"_handshake\", side_effect=_hs),\n        mock.patch.object(c, \"identify\") as m_ident,\n        mock.patch(\"time.monotonic\", return_value=0.0),\n        mock.patch(\"time.time\", side_effect=[0.0, 0.1, 0.2]),\n    ):\n        c.register(timeout=1.0, prefix=\"ap\")\n        # password cleared before registration when not SERVER\n        assert c.sm.ctx.password is None\n        m_ident.assert_called_once()\n\n    c2 = IRCClient(\n        host=\"h\",\n        nickname=\"n\",\n        fullname=\"f\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.SERVER,\n    )\n    c2.transport = mock.Mock()\n\n    def _hs2(deadline: float, prefix: str) -> None:\n        c2.sm.ctx.registered = True\n\n    with (\n        mock.patch.object(c2, \"_flush\"),\n        mock.patch.object(c2, \"_handshake\", side_effect=_hs2),\n        mock.patch.object(c2, \"identify\") as m_ident2,\n        mock.patch(\"time.monotonic\", return_value=0.0),\n        mock.patch(\"time.time\", side_effect=[0.0, 0.1, 0.2]),\n    ):\n        c2.register(timeout=1.0, prefix=\"ap\")\n        # identify not called for SERVER\n        m_ident2.assert_not_called()\n\n\ndef test_plugin_irc_client_join_privmsg_identify_quit() -> None:\n    \"\"\"Join and message helpers.\"\"\"\n    c = IRCClient(\n        host=\"h\",\n        nickname=\"n\",\n        fullname=\"f\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.NICKSERV,\n    )\n    c.transport = mock.Mock()\n\n    calls = {\"n\": 0}\n\n    def _mono() -> float:\n        calls[\"n\"] += 1\n        # Return 0.0 long enough to enter the loop, then jump past deadline\n        # to ensure join() exits.\n        return 0.0 if calls[\"n\"] < 5000 else 1.0\n\n    with (\n        mock.patch.object(c, \"_flush\") as m_flush,\n        mock.patch.object(c, \"_handshake\") as m_hs,\n        mock.patch(\"time.monotonic\", side_effect=_mono),\n    ):\n        # join never confirmed -> debug path\n        with mock.patch(\"apprise.plugins.irc.client.logger.debug\") as m_dbg:\n            c.join(channel=\"#c\", timeout=0.01, prefix=\"ap\", key=None)\n            m_dbg.assert_called()\n\n        assert m_flush.called\n        assert m_hs.called\n\n    # privmsg() and identify() also call monotonic; keep them in a separate\n    # context with a simple stable time.\n    with (\n        mock.patch.object(c, \"_flush\") as m_flush2,\n        mock.patch.object(c, \"_handshake\") as m_hs2,\n        mock.patch(\"time.monotonic\", return_value=0.0),\n    ):\n        c.privmsg(target=\"#c\", message=\"m\", timeout=0.1)\n        c.identify(timeout=0.1)\n        assert m_flush2.called\n        assert m_hs2.called\n\n    # identify exits early\n    c2 = IRCClient(\n        host=\"h\",\n        nickname=\"n\",\n        fullname=\"f\",\n        password=None,\n        auth_mode=IRCAuthMode.NICKSERV,\n    )\n    c2.transport = mock.Mock()\n    with mock.patch.object(c2, \"_flush\") as m_flush3:\n        c2.identify(timeout=0.1)\n        m_flush3.assert_not_called()\n\n    c3 = IRCClient(\n        host=\"h\",\n        nickname=\"n\",\n        fullname=\"f\",\n        password=\"pw\",\n        auth_mode=IRCAuthMode.SERVER,\n    )\n    c3.transport = mock.Mock()\n    with mock.patch.object(c3, \"_flush\") as m_flush4:\n        c3.identify(timeout=0.1)\n        m_flush4.assert_not_called()\n\n    # quit queues and flushes\n    c4 = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c4.transport = mock.Mock()\n    with mock.patch.object(c4, \"_flush\") as m_flush5:\n        c4.quit(message=\"bye\", timeout=0.1)\n        m_flush5.assert_called_once()\n\n\ndef test_plugin_irc_client_nick_generation_default_length() -> None:\n    \"\"\"Nick generation defaults.\"\"\"\n    nick = IRCClient.nick_generation(prefix=\"Ap\", length=None, collision=0)\n    assert len(nick) == IRCClient.nickname_max_length\n\n\ndef test_plugin_irc_client_write_trace() -> None:\n    \"\"\"Write trace logging.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    with (\n        mock.patch(\"time.monotonic\", return_value=0.0),\n        mock.patch(\n            \"apprise.plugins.irc.client.logger.isEnabledFor\",\n            return_value=True),\n        mock.patch(\n            \"apprise.plugins.irc.client.logger.trace\") as m_trace,\n    ):\n        c._write(\"HELLO\", deadline=1.0)\n        m_trace.assert_called_once()\n        # Ensure we wrote CRLF terminated bytes\n        c.transport.write.assert_called_once()\n        payload = c.transport.write.call_args.args[0]\n        assert payload.endswith(b\"\\r\\n\")\n\n\ndef test_plugin_irc_client_handshake_send_without_line() -> None:\n    \"\"\"Handshake ignores empty SEND.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    from apprise.plugins.irc.state import IRCAction, IRCActionKind\n\n    # One message then stop the loop\n    lines = [\":srv 001 n :welcome\", \"\"]\n    with (\n        mock.patch.object(\n            c, \"_read\", side_effect=lambda deadline: lines.pop(0)),\n        mock.patch.object(c.sm, \"on_message\", return_value=[\n            IRCAction(IRCActionKind.SEND, line=\"\")\n        ]),\n        mock.patch.object(c, \"_write\") as m_write,\n    ):\n        c._handshake(deadline=time.monotonic() + 1.0, prefix=\"\")\n        m_write.assert_not_called()\n\n\ndef test_plugin_irc_client_register_queue_ignores_empty_send() -> None:\n    \"\"\"Register ignores empty SEND.\"\"\"\n    c = IRCClient(\n        host=\"h\", nickname=\"n\", fullname=\"f\", auth_mode=IRCAuthMode.NONE)\n    c.transport = mock.Mock()\n\n    from apprise.plugins.irc.state import IRCAction, IRCActionKind\n\n    with (\n        mock.patch.object(c.sm, \"start_registration\", return_value=[\n            IRCAction(IRCActionKind.SEND, line=None),\n            IRCAction(IRCActionKind.SEND, line=\"\"),\n        ]),\n        mock.patch.object(c, \"_queue\") as m_queue,\n        mock.patch.object(c, \"_flush\"),\n        mock.patch.object(c, \"_handshake\"),\n        mock.patch(\"time.monotonic\", return_value=0.0),\n        mock.patch(\"time.time\", return_value=0.0),\n    ):\n        # Make it \"already registered\" to avoid the while loop behaviour\n        c.sm.ctx.registered = True\n        c.register(timeout=0.1, prefix=\"ap\")\n        m_queue.assert_not_called()\n\n\ndef test_plugin_irc_client_join_queue_ignores_empty_send() -> None:\n    \"\"\"Join ignores empty SEND.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    from apprise.plugins.irc.state import IRCAction, IRCActionKind\n\n    with (\n        mock.patch.object(c.sm, \"request_join\", return_value=[\n            IRCAction(IRCActionKind.SEND, line=None),\n            IRCAction(IRCActionKind.SEND, line=\"\"),\n        ]),\n        mock.patch.object(c, \"_queue\") as m_queue,\n        mock.patch.object(c, \"_flush\"),\n        mock.patch.object(c, \"_handshake\"),\n        mock.patch(\"time.monotonic\", return_value=1.0),\n    ):\n        # deadline will be 1.0 + timeout, but we also ensure we do not enter\n        # loop by setting joined to include channel.\n        c.sm.ctx.joined.add(\"#c\")\n        c.join(channel=\"#c\", timeout=0.1, prefix=\"ap\", key=None)\n        m_queue.assert_not_called()\n\n\ndef test_plugin_irc_client_quit_queue_ignores_empty_send() -> None:\n    \"\"\"Quit ignores empty SEND.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    from apprise.plugins.irc.state import IRCAction, IRCActionKind\n\n    with (\n        mock.patch.object(c.sm, \"request_quit\", return_value=[\n            IRCAction(IRCActionKind.SEND, line=None),\n            IRCAction(IRCActionKind.SEND, line=\"\"),\n        ]),\n        mock.patch.object(c, \"_queue\") as m_queue,\n        mock.patch.object(c, \"_flush\") as m_flush,\n        mock.patch(\"time.monotonic\", return_value=0.0),\n    ):\n        c.quit(message=\"bye\", timeout=0.1)\n        m_queue.assert_not_called()\n        m_flush.assert_called_once()\n\n\ndef test_plugin_irc_send_znc_calls_check_connection() -> None:\n    \"\"\"NotifyIRC ZNC mode triggers a connection check.\"\"\"\n    n = NotifyIRC(\n        host=\"irc.example.com\",\n        targets=[\"#chan\"],\n        user=\"zncuser\",\n        password=\"zncpass\",\n        mode=IRCAuthMode.ZNC,\n        nick=\"mybot\",\n    )\n\n    client = mock.Mock(spec=IRCClient)\n    client.nickname = \"mybot\"\n    client.check_connection.return_value = True\n\n    with mock.patch(\"apprise.plugins.irc.base.IRCClient\", return_value=client):\n        assert n.send(\"body\") is True\n\n    client.connect.assert_called_once()\n    client.register.assert_called_once()\n    client.check_connection.assert_called_once()\n    client.privmsg.assert_called_once()\n    client.quit.assert_called_once()\n    client.close.assert_called_once()\n\n\ndef test_plugin_irc_send_znc_check_connection_failure() -> None:\n    \"\"\"NotifyIRC ZNC mode fails fast if the bouncer liveness check fails.\"\"\"\n    n = NotifyIRC(\n        host=\"irc.example.com\",\n        targets=[\"#chan\"],\n        user=\"zncuser\",\n        password=\"zncpass\",\n        mode=IRCAuthMode.ZNC,\n        nick=\"mybot\",\n    )\n\n    client = mock.Mock(spec=IRCClient)\n    client.nickname = \"mybot\"\n    client.check_connection.return_value = False\n\n    with mock.patch(\"apprise.plugins.irc.base.IRCClient\", return_value=client):\n        assert n.send(\"body\") is False\n\n    client.connect.assert_called_once()\n    client.register.assert_called_once()\n    client.check_connection.assert_called_once()\n    client.privmsg.assert_not_called()\n    client.quit.assert_not_called()\n    client.close.assert_called_once()\n\n\ndef test_plugin_irc_send_znc_pass_rewrite() -> None:\n    \"\"\"NotifyIRC ZNC mode passes user:pass to IRCClient password.\"\"\"\n    n = NotifyIRC(\n        host=\"irc.example.com\",\n        targets=[\"#chan\"],\n        user=\"zncuser\",\n        password=\"zncpass\",\n        mode=IRCAuthMode.ZNC,\n        nick=\"mybot\",\n    )\n\n    client = mock.Mock(spec=IRCClient)\n    client.nickname = \"mybot\"\n    client.check_connection.return_value = True\n\n    with mock.patch(\n            \"apprise.plugins.irc.base.IRCClient\",\n            return_value=client) as m_ctor:\n        assert n.send(\"body\") is True\n\n    # Pull the kwargs passed into IRCClient(...)\n    kwargs = m_ctor.call_args.kwargs\n    assert kwargs[\"password\"] == \"zncuser:zncpass\"\n\n\ndef test_plugin_irc_client_check_connection_success_on_any_pong() -> None:\n    \"\"\"check_connection() returns True when a PONG is observed.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    # 0.0 used to compute deadline, then remain within deadline once, then stop\n    times = [0.0, 0.0, 0.0]\n\n    def _mono() -> float:\n        return times.pop(0) if times else 10.0\n\n    with (\n        mock.patch(\"time.monotonic\", side_effect=_mono),\n        mock.patch.object(c, \"_write\") as m_write,\n        mock.patch.object(c, \"_read\", side_effect=[\"PONG :server\"]) as m_read,\n    ):\n        assert c.check_connection(timeout=5.0) is True\n        m_write.assert_called_once()\n        m_read.assert_called()\n\n\ndef test_plugin_irc_client_handles_empty_reads() -> None:\n    \"\"\"check_connection() handles empty reads and returns False at deadline.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    # Start at 0.0, enter loop once, then exceed deadline after the empty read.\n    times = [0.0, 0.0, 6.0]\n\n    def _mono() -> float:\n        return times.pop(0)\n\n    with (\n        mock.patch(\"time.monotonic\", side_effect=_mono),\n        mock.patch.object(c, \"_write\") as m_write,\n        mock.patch.object(c, \"_read\", return_value=None),\n    ):\n        assert c.check_connection(timeout=5.0) is False\n        m_write.assert_called_once()\n\n\ndef test_plugin_irc_client_join_queues_join_line_and_completes() -> None:\n    \"\"\"join() queues the JOIN command when request_join yields a SEND line.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    def _handshake(deadline: float, prefix: str) -> None:\n        # Simulate join confirmation being observed\n        c.sm.ctx.joined.add(\"#c\")\n\n    with (\n        mock.patch(\"time.monotonic\", return_value=0.0),\n        mock.patch.object(c, \"_queue\") as m_queue,\n        mock.patch.object(c, \"_flush\"),\n        mock.patch.object(c, \"_handshake\", side_effect=_handshake),\n    ):\n        c.join(channel=\"#c\", timeout=1.0, prefix=\"ap\", key=None)\n\n    # Ensure the queue branch was taken\n    assert any(\n        call.args and isinstance(\n            call.args[0], str) and call.args[0].startswith(\"JOIN \")\n        for call in m_queue.call_args_list\n    )\n\n\ndef test_plugin_irc_client_join_timeout_logs_debug() -> None:\n    \"\"\"join() emits a debug line when confirmation is not observed.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    # timeout=0.0 forces the while-loop to be skipped and triggers debug path\n    with (\n        mock.patch(\"time.monotonic\", return_value=0.0),\n        mock.patch(\"apprise.plugins.irc.client.logger.debug\") as m_dbg,\n        mock.patch.object(c, \"_flush\"),\n        mock.patch.object(c, \"_handshake\"),\n    ):\n        c.join(channel=\"#c\", timeout=0.0, prefix=\"ap\", key=None)\n        m_dbg.assert_called_once()\n\n\ndef test_plugin_irc_state_machine_joining_numeric_443_adds_channel() -> None:\n    \"\"\"State machine treats numeric 443 as joined confirmation.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n\n    # Enter JOINING state\n    sm.request_join(\"#c\")\n\n    assert sm.state == IRCState.JOINING\n\n    # 443 usually indicates already on channel; this should mark as joined.\n    sm.on_message(parse_irc_line(\":srv 443 n #c :is already on channel\"))\n\n    assert \"#c\" in sm.ctx.joined\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_client_connection_check() -> None:\n    \"\"\"check_connection() sees a non-PONG message.\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    # monotonic() is used to compute deadline, then loop condition.\n    # We provide values such that we enter the loop at least once, then\n    # exceed deadline.\n    times = [0.0, 0.0, 0.0, 6.0]\n\n    def _mono() -> float:\n        return times.pop(0)\n\n    with (\n        mock.patch(\"time.monotonic\", side_effect=_mono),\n        mock.patch.object(c, \"_write\") as m_write,\n        # First read returns a non-empty line that is not a PONG, then nothing.\n        mock.patch.object(c, \"_read\", side_effect=[\"PING :abc\", None]),\n    ):\n        assert c.check_connection(timeout=5.0) is False\n        m_write.assert_called_once()\n\n\ndef test_plugin_irc_client_join_non_send_actions() -> None:\n    \"\"\"join() ignores non-SEND actions\"\"\"\n    c = IRCClient(host=\"h\", nickname=\"n\", fullname=\"f\")\n    c.transport = mock.Mock()\n\n    from apprise.plugins.irc.state import IRCAction, IRCActionKind\n\n    # Return a NOOP action so: act.kind == SEND is False\n    actions = [IRCAction(IRCActionKind.NOOP)]\n\n    # Skip the while loop by making monotonic() already past deadline\n    with (\n        mock.patch.object(c.sm, \"request_join\", return_value=actions),\n        mock.patch.object(c, \"_queue\") as m_queue,\n        mock.patch.object(c, \"_flush\") as m_flush,\n        mock.patch.object(c, \"_handshake\") as m_hs,\n        mock.patch(\"time.monotonic\", side_effect=[0.0, 1.0]),\n        mock.patch(\"apprise.plugins.irc.client.logger.debug\") as m_dbg,\n    ):\n        c.join(channel=\"#c\", timeout=0.0, prefix=\"ap\", key=None)\n\n        # Nothing queued because our action was not SEND\n        m_queue.assert_not_called()\n\n        # While loop is skipped, so these are not called\n        m_flush.assert_not_called()\n        m_hs.assert_not_called()\n\n        # Not joined, so debug path executes\n        m_dbg.assert_called_once()\n"
  },
  {
    "path": "tests/test_plugin_irc_state.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"Tests for IRC state machine.\"\"\"\n\nfrom __future__ import annotations\n\nfrom apprise.plugins.irc.protocol import parse_irc_line\nfrom apprise.plugins.irc.state import (\n    IRCActionKind,\n    IRCContext,\n    IRCState,\n    IRCStateMachine,\n    _err,\n)\n\n\ndef test_plugin_irc_state_err_trailing() -> None:\n    \"\"\"Error text from trailing.\"\"\"\n    msg = parse_irc_line(\":srv 464 nick :bad pass\")\n    assert _err(msg) == \"bad pass\"\n\n\ndef test_plugin_irc_state_err_params() -> None:\n    \"\"\"Error text from params.\"\"\"\n    msg = parse_irc_line(\":srv 471 nick #c\")\n    # no trailing, params join\n    assert _err(msg) == \"nick #c\"\n\n\ndef test_plugin_irc_state_err_default() -> None:\n    \"\"\"Error text default.\"\"\"\n    msg = parse_irc_line(\":srv 471\")\n    assert _err(msg) == \"IRC error\"\n\n\ndef test_plugin_irc_state_ignore_when_error_or_quitting() -> None:\n    \"\"\"Ignore messages when exiting.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n\n    sm.state = IRCState.ERROR\n    assert sm.on_message(parse_irc_line(\"PING :x\")) == []\n\n    sm.state = IRCState.QUITTING\n    assert sm.on_message(parse_irc_line(\":srv 001 n :welcome\")) == []\n\n\ndef test_plugin_irc_state_register_error_sets_last_error() -> None:\n    \"\"\"Register errors fail.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    actions = sm.on_message(parse_irc_line(\":srv 464 n :bad pass\"))\n    assert sm.state == IRCState.ERROR\n    assert ctx.last_error is not None\n    assert actions and actions[0].kind == IRCActionKind.FAIL\n    assert \"Password incorrect\" in (actions[0].reason or \"\")\n\n\ndef test_plugin_irc_state_register_collision_433_sends_new_nick() -> None:\n    \"\"\"Nick collision 433.\"\"\"\n    ctx = IRCContext(desired_nick=\"new\", accepted_nick=\"old\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    actions = sm.on_message(parse_irc_line(\":srv 433 old :in use\"))\n    assert actions and actions[0].kind == IRCActionKind.SEND\n    assert actions[0].line == \"NICK new\"\n\n\ndef test_plugin_irc_state_register_collision_432_sends_new_nick() -> None:\n    \"\"\"Nick collision 432.\"\"\"\n    ctx = IRCContext(desired_nick=\"new\", accepted_nick=\"old\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    actions = sm.on_message(parse_irc_line(\":srv 432 old :bad nick\"))\n    assert actions and actions[0].kind == IRCActionKind.SEND\n    assert actions[0].line == \"NICK new\"\n\n\ndef test_plugin_irc_state_register_welcome_sets_accepted() -> None:\n    \"\"\"Welcome sets accepted nick.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    sm.on_message(parse_irc_line(\":srv 001 nick :welcome\"))\n    assert ctx.accepted_nick == \"nick\"\n    assert ctx.registered is True\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_state_register_welcome() -> None:\n    \"\"\"Welcome without nick keeps accepted.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"keep\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    # No nick in params, extract_welcome_nick() returns empty/None.\n    sm.on_message(parse_irc_line(\":srv 001 :welcome\"))\n    assert ctx.accepted_nick == \"keep\"\n    assert ctx.registered is True\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_state_register_motd_done_before_registered() -> None:\n    \"\"\"MOTD sets motd_done.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    sm.on_message(parse_irc_line(\":srv 422 n :MOTD missing\"))\n    assert ctx.motd_done is True\n    assert sm.state == IRCState.REGISTERING\n\n\ndef test_plugin_irc_state_register_motd_done_376_before_registered() -> None:\n    \"\"\"MOTD 376 sets motd_done.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    sm.on_message(parse_irc_line(\":srv 376 n :End of MOTD\"))\n    assert ctx.motd_done is True\n    assert ctx.registered is False\n    assert sm.state == IRCState.REGISTERING\n\n\ndef test_plugin_irc_state_register_motd_done_after_registered() -> None:\n    \"\"\"MOTD sets ready when registered.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    # This branch requires REGISTERING when 376/422 arrives.\n    ctx.registered = True\n    sm.state = IRCState.REGISTERING\n\n    sm.on_message(parse_irc_line(\":srv 376 n :End of MOTD\"))\n    assert ctx.motd_done is True\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_state_join_error_sets_last_error() -> None:\n    \"\"\"Join errors fail.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.request_join(\"#c\", key=None)\n\n    actions = sm.on_message(parse_irc_line(\":srv 475 n #c :Bad key\"))\n    assert sm.state == IRCState.ERROR\n    assert ctx.last_error is not None\n    assert actions and actions[0].kind == IRCActionKind.FAIL\n    assert \"Bad channel key\" in (actions[0].reason or \"\")\n\n\ndef test_plugin_irc_state_join_numeric_366_adds_channel() -> None:\n    \"\"\"Join complete 366.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.request_join(\"#c\", key=None)\n\n    sm.on_message(parse_irc_line(\":srv 366 n #c :End of /NAMES list.\"))\n    assert \"#c\" in ctx.joined\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_state_join_command_trailing() -> None:\n    \"\"\"Join complete JOIN trailing.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.request_join(\"#c\", key=None)\n\n    sm.on_message(parse_irc_line(\":nick!u@h JOIN :#c\"))\n    assert \"#c\" in ctx.joined\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_state_join_command_params() -> None:\n    \"\"\"Join complete JOIN params.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.request_join(\"#c\", key=None)\n\n    sm.on_message(parse_irc_line(\":nick!u@h JOIN #d\"))\n    assert \"#d\" in ctx.joined\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_state_request_join_key_and_no_key() -> None:\n    \"\"\"Join requests render.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n\n    a1 = sm.request_join(\"#c\", key=None)\n    assert sm.state == IRCState.JOINING\n    assert a1 and a1[0].line == \"JOIN #c\"\n\n    a2 = sm.request_join(\"#c\", key=\"k\")\n    assert a2 and a2[0].line == \"JOIN #c k\"\n\n\ndef test_plugin_irc_state_request_quit() -> None:\n    \"\"\"Quit request renders.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n\n    actions = sm.request_quit(\"bye\")\n    assert sm.state == IRCState.QUITTING\n    assert actions and actions[0].line == \"QUIT :bye\"\n\n\ndef test_plugin_irc_state_register_unhandled_numeric() -> None:\n    \"\"\"Register ignores unhandled numerics.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.start_registration()\n\n    # Numeric 2 is not handled by REGISTERING logic\n    actions = sm.on_message(parse_irc_line(\":srv 002 n :Your host is\"))\n    assert actions == []\n    assert sm.state == IRCState.REGISTERING\n    assert ctx.registered is False\n    assert ctx.motd_done is False\n\n\ndef test_plugin_irc_state_join_command_empty_channel() -> None:\n    \"\"\"Join ignores empty JOIN channel.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.request_join(\"#c\", key=None)\n\n    # JOIN with no params and no trailing yields empty channel\n    actions = sm.on_message(parse_irc_line(\"JOIN\"))\n    assert actions == []\n    assert ctx.joined == set()\n    assert sm.state == IRCState.JOINING\n\n\ndef test_plugin_irc_state_ready_falls_through() -> None:\n    \"\"\"Ready falls through to default return.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.state = IRCState.READY\n\n    # Not handled in READY state, should return empty actions via final return\n    actions = sm.on_message(parse_irc_line(\":nick!u@h PRIVMSG #c :hi\"))\n    assert actions == []\n    assert sm.state == IRCState.READY\n\n\ndef test_plugin_irc_state_join_non_join_command() -> None:\n    \"\"\"Join ignores non-JOIN commands.\"\"\"\n    ctx = IRCContext(desired_nick=\"n\", accepted_nick=\"n\", fullname=\"f\")\n    sm = IRCStateMachine(ctx)\n    sm.request_join(\"#c\", key=None)\n\n    # Not a JOIN command, not a join numeric, and not an error\n    actions = sm.on_message(parse_irc_line(\":nick!u@h PRIVMSG #c :hi\"))\n    assert actions == []\n    assert sm.state == IRCState.JOINING\n    assert ctx.joined == set()\n"
  },
  {
    "path": "tests/test_plugin_jellyfin.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise import Apprise\nfrom apprise.plugins import jellyfin\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\n# Note: The majority of tests are already handled by the emby:// adaptation\n\napprise_url_tests = (\n    # Insecure Request; no hostname specified\n    (\n        \"jellyfin://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # Secure Request; no hostname specified\n    (\n        \"jellyfins://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # No user specified\n    (\n        \"jellyfin://localhost\",\n        {\n            # Missing a username\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"jellyfin://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # Valid Authentication (we do not validate credentials here)\n    (\n        \"jellyfin://l2g@localhost\",\n        {\n            \"instance\": jellyfin.NotifyJellyfin,\n            # Authentication can't be validated through these unit tests\n            \"response\": False,\n        },\n    ),\n    (\n        \"jellyfins://l2g:password@localhost\",\n        {\n            \"instance\": jellyfin.NotifyJellyfin,\n            \"response\": False,\n            \"privacy_url\": \"jellyfins://l2g:****@localhost\",\n        },\n    ),\n)\n\n\ndef test_plugin_jellyfin_urls():\n    \"\"\"NotifyJellyfin() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_jellyfin_instantiation():\n    \"\"\"NotifyJellyfin() instantiation tests.\"\"\"\n\n    obj = Apprise.instantiate(\"jellyfin://l2g:l2gpass@localhost\")\n    assert isinstance(obj, jellyfin.NotifyJellyfin)\n\n    obj = Apprise.instantiate(\"jellyfins://l2g:l2gpass@localhost\")\n    assert isinstance(obj, jellyfin.NotifyJellyfin)\n"
  },
  {
    "path": "tests/test_plugin_join.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nimport apprise\nfrom apprise import NotifyType\nfrom apprise.plugins.join import JoinPriority, NotifyJoin\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"join://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # API Key + bad url\n    (\n        \"join://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # APIkey; no device\n    (\n        \"join://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    # API Key + device (using to=)\n    (\n        \"join://{}?to={}\".format(\"a\" * 32, \"d\" * 32),\n        {\n            \"instance\": NotifyJoin,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"join://a...a/\",\n        },\n    ),\n    # API Key + priority setting\n    (\n        \"join://%s?priority=high\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    # API Key + invalid priority setting\n    (\n        \"join://%s?priority=invalid\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    # API Key + priority setting (empty)\n    (\n        \"join://%s?priority=\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    # API Key + device\n    (\n        \"join://{}@{}?image=True\".format(\"a\" * 32, \"d\" * 32),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    # No image\n    (\n        \"join://{}@{}?image=False\".format(\"a\" * 32, \"d\" * 32),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    # API Key + Device Name\n    (\n        \"join://{}/{}\".format(\"a\" * 32, \"My Device\"),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    # API Key + device\n    (\n        \"join://{}/{}\".format(\"a\" * 32, \"d\" * 32),\n        {\n            \"instance\": NotifyJoin,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # API Key + 2 devices\n    (\n        \"join://{}/{}/{}\".format(\"a\" * 32, \"d\" * 32, \"e\" * 32),\n        {\n            \"instance\": NotifyJoin,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # API Key + 1 device and 1 group\n    (\n        \"join://{}/{}/{}\".format(\"a\" * 32, \"d\" * 32, \"group.chrome\"),\n        {\n            \"instance\": NotifyJoin,\n        },\n    ),\n    (\n        \"join://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyJoin,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"join://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyJoin,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"join://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyJoin,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_join_urls():\n    \"\"\"NotifyJoin() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_join_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyJoin() Edge Cases.\"\"\"\n\n    # Generate some generic message types\n    device = \"A\" * 32\n    group = \"group.chrome\"\n    apikey = \"a\" * 32\n\n    # Initializes the plugin with devices set to a string\n    NotifyJoin(apikey=apikey, targets=group)\n\n    # Initializes the plugin with devices set to None\n    NotifyJoin(apikey=apikey, targets=None)\n\n    # Initializes the plugin with an invalid apikey\n    with pytest.raises(TypeError):\n        NotifyJoin(apikey=None)\n\n    # Whitespace also acts as an invalid apikey\n    with pytest.raises(TypeError):\n        NotifyJoin(apikey=\"   \")\n\n    # Initializes the plugin with devices set to a set\n    p = NotifyJoin(apikey=apikey, targets=[group, device])\n\n    # Prepare our mock responses\n    req = requests.Request()\n    req.status_code = requests.codes.created\n    req.content = \"\"\n    mock_get.return_value = req\n    mock_post.return_value = req\n\n    # Test notifications without a body or a title; nothing to send\n    # so we return False\n    assert (\n        p.notify(body=None, title=None, notify_type=NotifyType.INFO) is False\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_join_config_files(mock_post):\n    \"\"\"NotifyJoin() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - join://{}@{}:\n          - priority: -2\n            tag: join_int low\n          - priority: \"-2\"\n            tag: join_str_int low\n          - priority: low\n            tag: join_str low\n\n          # This will take on normal (default) priority\n          - priority: invalid\n            tag: join_invalid\n\n      - join://{}@{}:\n          - priority: 2\n            tag: join_int emerg\n          - priority: \"2\"\n            tag: join_str_int emerg\n          - priority: emergency\n            tag: join_str emerg\n    \"\"\".format(\"a\" * 32, \"b\" * 32, \"c\" * 32, \"d\" * 32)\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 7 servers from that\n    # 3x low\n    # 3x emerg\n    # 1x invalid (so takes on normal priority)\n    assert len(ac.servers()) == 7\n    assert len(aobj) == 7\n    assert len(list(aobj.find(tag=\"low\"))) == 3\n    for s in aobj.find(tag=\"low\"):\n        assert s.priority == JoinPriority.LOW\n\n    assert len(list(aobj.find(tag=\"emerg\"))) == 3\n    for s in aobj.find(tag=\"emerg\"):\n        assert s.priority == JoinPriority.EMERGENCY\n\n    assert len(list(aobj.find(tag=\"join_str\"))) == 2\n    assert len(list(aobj.find(tag=\"join_str_int\"))) == 2\n    assert len(list(aobj.find(tag=\"join_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"join_invalid\"))) == 1\n    assert next(aobj.find(tag=\"join_invalid\")).priority == JoinPriority.NORMAL\n\n    # Notifications work\n    assert aobj.notify(title=\"title\", body=\"body\") is True\n"
  },
  {
    "path": "tests/test_plugin_kavenegar.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.kavenegar import NotifyKavenegar\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"kavenegar://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"kavenegar://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"kavenegar://{}/{}/{}\".format(\"1\" * 10, \"2\" * 15, \"a\" * 13),\n        {\n            # valid api key and valid authentication\n            \"instance\": NotifyKavenegar,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"kavenegar://{}/{}\".format(\"a\" * 24, \"3\" * 14),\n        {\n            # valid api key and valid number\n            \"instance\": NotifyKavenegar,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kavenegar://a...a/\",\n        },\n    ),\n    (\n        \"kavenegar://{}?to={}\".format(\"a\" * 24, \"3\" * 14),\n        {\n            # valid api key and valid number\n            \"instance\": NotifyKavenegar,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kavenegar://a...a/\",\n        },\n    ),\n    (\n        \"kavenegar://{}@{}/{}\".format(\"1\" * 14, \"b\" * 24, \"3\" * 14),\n        {\n            # valid api key and valid number\n            \"instance\": NotifyKavenegar,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kavenegar://{}@b...b/\".format(\"1\" * 14),\n        },\n    ),\n    (\n        \"kavenegar://{}@{}/{}\".format(\"a\" * 14, \"b\" * 24, \"3\" * 14),\n        {\n            # invalid from number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"kavenegar://{}@{}/{}\".format(\"3\" * 4, \"b\" * 24, \"3\" * 14),\n        {\n            # invalid from number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"kavenegar://{}/{}?from={}\".format(\"b\" * 24, \"3\" * 14, \"1\" * 14),\n        {\n            # valid api key and valid number\n            \"instance\": NotifyKavenegar,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kavenegar://{}@b...b/\".format(\"1\" * 14),\n        },\n    ),\n    (\n        \"kavenegar://{}/{}\".format(\"b\" * 24, \"4\" * 14),\n        {\n            \"instance\": NotifyKavenegar,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"kavenegar://{}/{}\".format(\"c\" * 24, \"5\" * 14),\n        {\n            \"instance\": NotifyKavenegar,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_kavenegar_urls():\n    \"\"\"NotifyKavenegar() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_kumulos.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.kumulos import NotifyKumulos\n\nlogging.disable(logging.CRITICAL)\n\n# a test UUID we can use\nUUID4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"kumulos://\",\n        {\n            # No API or Server Key specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"kumulos://:@/\",\n        {\n            # No API or Server Key specified\n            # We don't have strict host checking on for kumulos, so this URL\n            # actually becomes parseable and :@ becomes a hostname.\n            # The below errors because a second token wasn't found\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"kumulos://{UUID4}/\",\n        {\n            # No server key was specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"kumulos://{}/{}/\".format(UUID4, \"w\" * 36),\n        {\n            # Everything is okay\n            \"instance\": NotifyKumulos,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kumulos://8...2/w...w/\",\n        },\n    ),\n    (\n        \"kumulos://{}/{}/\".format(UUID4, \"x\" * 36),\n        {\n            \"instance\": NotifyKumulos,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kumulos://8...2/x...x/\",\n        },\n    ),\n    (\n        \"kumulos://{}/{}/\".format(UUID4, \"y\" * 36),\n        {\n            \"instance\": NotifyKumulos,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kumulos://8...2/y...y/\",\n        },\n    ),\n    (\n        \"kumulos://{}/{}/\".format(UUID4, \"z\" * 36),\n        {\n            \"instance\": NotifyKumulos,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_kumulos_urls():\n    \"\"\"NotifyKumulos() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_kumulos_edge_cases():\n    \"\"\"NotifyKumulos() Edge Cases.\"\"\"\n\n    # Invalid API Key\n    with pytest.raises(TypeError):\n        NotifyKumulos(None, None)\n    with pytest.raises(TypeError):\n        NotifyKumulos(\"     \", None)\n\n    # Invalid Server Key\n    with pytest.raises(TypeError):\n        NotifyKumulos(\"abcd\", None)\n    with pytest.raises(TypeError):\n        NotifyKumulos(\"abcd\", \"       \")\n"
  },
  {
    "path": "tests/test_plugin_lametric.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.lametric import NotifyLametric\n\nlogging.disable(logging.CRITICAL)\n\n# a test UUID we can use\nUUID4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"lametric://\",\n        {\n            # No APIKey or App ID specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"lametric://:@/\",\n        {\n            # No APIKey or App ID specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"lametric://{}/\".format(\n            \"com.lametric.941c51dff3135bd87aa72db9d855dd50\"\n        ),\n        {\n            # No APIKey specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"lametric://root:{UUID4}@192.168.0.5:8080/\",\n        {\n            # Everything is okay; this would be picked up in Device Mode\n            # We're using a default port and enforcing a special user\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://root:8...2@192.168.0.5/\",\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.0.4:8000/\",\n        {\n            # Everything is okay; this would be picked up in Device Mode\n            # Port is enforced\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://8...2@192.168.0.4:8000/\",\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.0.5/\",\n        {\n            # Everything is okay; this would be picked up in Device Mode\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://8...2@192.168.0.5/\",\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.0.6/?mode=device\",\n        {\n            # Everything is okay; Device mode forced\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametrics://8...2@192.168.0.6/\",\n        },\n    ),\n    # Support Native URL (with Access Token Argument)\n    (\n        \"https://developer.lametric.com/api/v1/dev/widget/update/\"\n        \"com.lametric.ABCD123/1?token={}==\".format(\"D\" * 88),\n        {\n            # Everything is okay; Device mode forced\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://D...=@A...3/1/\",\n        },\n    ),\n    (\n        \"lametric://192.168.2.8/?mode=device&apikey=abc123\",\n        {\n            # Everything is okay; Device mode forced\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://a...3@192.168.2.8/\",\n        },\n    ),\n    (\n        \"lametrics://{}==@com.lametric.941c51dff3135bd87aa72db9d855dd50/\"\n        \"?mode=cloud&app_ver=2\".format(\"A\" * 88),\n        {\n            # Everything is okay; Cloud mode forced\n            # We gracefully strip off the com.lametric. part as well\n            # We also set an application version of 2\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://A...=@9...0/\",\n        },\n    ),\n    (\n        \"lametrics://{}==@com.lametric.941c51dff3135bd87aa72db9d855dd50/\"\n        \"?app_ver=invalid\".format(\"A\" * 88),\n        {\n            # We set invalid app version\n            \"instance\": TypeError,\n        },\n    ),\n    # our lametric object initialized via argument\n    (\n        \"lametric://?app=com.lametric.941c51dff3135bd87aa72db9d855dd50&token={}==\"\n        \"&mode=cloud\".format(\"B\" * 88),\n        {\n            # Everything is okay; Cloud mode forced\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://B...=@9...0/\",\n        },\n    ),\n    (\n        \"lametrics://{}==@abcd/?mode=cloud&sound=knock&icon_type=info\"\n        \"&priority=critical&cycles=10\".format(\"C\" * 88),\n        {\n            # Cloud mode forced, sound, icon_type, and priority not supported\n            # with cloud mode so warnings are created\n            \"instance\": NotifyLametric,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://C...=@a...d/\",\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.0.7/?mode=invalid\",\n        {\n            # Invalid Mode\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.0.6/?sound=alarm1\",\n        {\n            # Device mode with sound set to alarm1\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.0.7/?sound=bike\",\n        {\n            # Device mode with sound set to bicycle using alias\n            \"instance\": NotifyLametric,\n            # Bike is an alias,\n            \"url_matches\": r\"sound=bicycle\",\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.0.8/?sound=invalid!\",\n        {\n            # Invalid sounds just produce warnings... object still loads\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.0.9/?icon_type=alert\",\n        {\n            # Icon Type Changed\n            \"instance\": NotifyLametric,\n            # icon=alert exists somewhere on our generated URL\n            \"url_matches\": r\"icon_type=alert\",\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.0.10/?icon_type=invalid\",\n        {\n            # Invalid icon types just produce warnings... object still loads\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.1.1/?priority=warning\",\n        {\n            # Priority changed\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.1.2/?priority=invalid\",\n        {\n            # Invalid priority just produce warnings... object still loads\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.1.2/?icon=230\",\n        {\n            # Our custom icon by it's ID\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.1.2/?icon=#230\",\n        {\n            # Our custom icon by it's ID; the hashtag at the front is ignored\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.1.2/?icon=Heart\",\n        {\n            # Our custom icon; the hashtag at the front is ignored\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.1.2/?icon=#\",\n        {\n            # a hashtag and nothing else\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.1.2/?icon=#%20%20%20\",\n        {\n            # a hashtag and some spaces\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.1.3/?cycles=2\",\n        {\n            # Cycles changed\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@192.168.1.4/?cycles=-1\",\n        {\n            # Cycles changed (out of range)\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@192.168.1.5/?cycles=invalid\",\n        {\n            # Invalid priority just produce warnings... object still loads\n            \"instance\": NotifyLametric,\n        },\n    ),\n    (\n        f\"lametric://{UUID4}@example.com/\",\n        {\n            \"instance\": NotifyLametric,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametric://8...2@example.com/\",\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@example.ca/\",\n        {\n            \"instance\": NotifyLametric,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lametrics://8...2@example.ca/\",\n        },\n    ),\n    (\n        f\"lametrics://{UUID4}@example.net/\",\n        {\n            \"instance\": NotifyLametric,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_lametric_urls():\n    \"\"\"NotifyLametric() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_lametric_edge_cases():\n    \"\"\"NotifyLametric() Edge Cases.\"\"\"\n    # Initializes the plugin with an invalid API Key\n    with pytest.raises(TypeError):\n        NotifyLametric(apikey=None, mode=\"device\")\n\n    # Initializes the plugin with an invalid Client Secret\n    with pytest.raises(TypeError):\n        NotifyLametric(client_id=\"valid\", secret=None, mode=\"cloud\")\n"
  },
  {
    "path": "tests/test_plugin_lark.py",
    "content": "# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.lark import NotifyLark\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"lark://\",\n        {\n            # Teams Token missing\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"lark://:@/\",\n        {\n            # We don't have strict host checking on for lark, so this URL\n            # actually becomes parseable and :@ becomes a hostname.\n            # The below errors because a second token wasn't found\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"lark://{}\".format(\"abcd-1234\"),\n        {\n            # token provided - we're good\n            \"instance\": NotifyLark,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lark://****/\",\n        },\n    ),\n    (\n        \"lark://{}\".format(\"abcd-1234\"),\n        {\n            # token provided - we're good\n            \"instance\": NotifyLark,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lark://****/\",\n        },\n    ),\n    (\n        \"lark://?token={}\".format(\"abcd-1234\"),\n        {\n            # token provided - we're good\n            \"instance\": NotifyLark,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"lark://****/\",\n        },\n    ),\n    # Support Native URLs with arguments\n    (\n        \"https://open.larksuite.com/open-apis/bot/v2/hook/{}\".format(\n            \"abcd-1234\"\n        ),\n        {\n            # token provided - we're good\n            \"instance\": NotifyLark,\n        },\n    ),\n    (\n        \"lark://{}\".format(\"abcd-1234\"),\n        {\n            \"instance\": NotifyLark,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"lark://{}\".format(\"abcd-1234\"),\n        {\n            \"instance\": NotifyLark,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"lark://{}\".format(\"a\" * 80),\n        {\n            \"instance\": NotifyLark,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_lark_urls():\n    \"\"\"NotifyLark() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_line.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.line import NotifyLine\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"line://\",\n        {\n            # No Access Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"line://%20/\",\n        {\n            # invalid Access Token; no Integration/Routing Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"line://token\",\n        {\n            # no target specified\n            \"instance\": NotifyLine,\n            # Expected notify() response\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"line://token=/target\",\n        {\n            # minimum requirements met\n            \"instance\": NotifyLine,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"line://****/t...t?\",\n        },\n    ),\n    (\n        \"line://token/target?image=no\",\n        {\n            # minimum requirements met; no icon display\n            \"instance\": NotifyLine,\n        },\n    ),\n    (\n        \"line://a/very/long/token=/target?image=no\",\n        {\n            # minimum requirements met; no icon display\n            \"instance\": NotifyLine,\n        },\n    ),\n    (\n        \"line://?token=token&to=target1\",\n        {\n            # minimum requirements met\n            \"instance\": NotifyLine,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"line://****/t...1?\",\n        },\n    ),\n    (\n        \"line://token/target\",\n        {\n            \"instance\": NotifyLine,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"line://token/target\",\n        {\n            \"instance\": NotifyLine,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_line_urls():\n    \"\"\"NotifyLine() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_macosx.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport os\nimport sys\nfrom unittest.mock import Mock\n\nfrom helpers import reload_plugin\nimport pytest\n\nimport apprise\nfrom apprise.plugins.macosx import NotifyMacOSX\n\n# Disable logging for a cleaner testing output.\nlogging.disable(logging.CRITICAL)\n\n\nif sys.platform not in [\"darwin\", \"linux\"]:\n    pytest.skip(\n        \"Only makes sense on macOS, but testable in Linux\",\n        allow_module_level=True,\n    )\n\n\n@pytest.fixture\ndef pretend_macos(mocker):\n    \"\"\"Fixture to simulate a macOS environment.\"\"\"\n    mocker.patch(\"platform.system\", return_value=\"Darwin\")\n    mocker.patch(\"platform.mac_ver\", return_value=(\"10.8\", (\"\", \"\", \"\"), \"\"))\n\n    # Reload plugin module, in order to re-run module-level code.\n    reload_plugin(\"macosx\")\n\n\n@pytest.fixture\ndef terminal_notifier(mocker, tmp_path):\n    \"\"\"Fixture for providing a surrogate for the `terminal-notifier`\n    program.\"\"\"\n    notifier_program = tmp_path.joinpath(\"terminal-notifier\")\n    notifier_program.write_text(\"#!/bin/sh\\n\\necho hello\")\n\n    # Set execute bit.\n    os.chmod(notifier_program, 0o755)\n\n    # Make the notifier use the temporary file instead of `terminal-notifier`.\n    mocker.patch(\n        \"apprise.plugins.macosx.NotifyMacOSX.notify_paths\",\n        (str(notifier_program),),\n    )\n\n    yield notifier_program\n\n\n@pytest.fixture\ndef macos_notify_environment(pretend_macos, terminal_notifier):\n    \"\"\"Fixture to bundle general test case setup.\n\n    Use this fixture if you don't need access to the individual members.\n    \"\"\"\n    pass\n\n\ndef test_plugin_macosx_general_success(macos_notify_environment):\n    \"\"\"NotifyMacOSX() general checks.\"\"\"\n\n    # Toggle Enable Flag\n    obj = apprise.Apprise.instantiate(\n        \"macosx://_/?image=True\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMacOSX) is True\n\n    # Test url() call\n    assert isinstance(obj.url(), str) is True\n\n    # URL Identifier has been disabled as this isn't unique enough\n    # to be mapped to more the 1 end point; verify that None is always\n    # returned\n    assert obj.url_id() is None\n\n    # test notifications\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    # test notification without a title\n    assert (\n        obj.notify(title=\"\", body=\"body\", notify_type=apprise.NotifyType.INFO)\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"macosx://_/?image=True\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMacOSX) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"macosx://_/?image=False\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMacOSX) is True\n    assert isinstance(obj.url(), str) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    # Test Sound\n    obj = apprise.Apprise.instantiate(\n        \"macosx://_/?sound=default\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMacOSX) is True\n    assert obj.sound == \"default\"\n    assert isinstance(obj.url(), str) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    # Test Click (-open support)\n    obj = apprise.Apprise.instantiate(\n        \"macosx://_/?click=http://google.com\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMacOSX) is True\n    assert obj.click == \"http://google.com\"\n    assert isinstance(obj.url(), str) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n\ndef test_plugin_macosx_terminal_notifier_not_executable(\n    pretend_macos, terminal_notifier\n):\n    \"\"\"When the `terminal-notifier` program is inaccessible or not executable,\n    we are unable to send notifications.\"\"\"\n\n    obj = apprise.Apprise.instantiate(\"macosx://\", suppress_exceptions=False)\n\n    # Unset the executable bit.\n    os.chmod(terminal_notifier, 0o644)\n\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n\n\ndef test_plugin_macosx_terminal_notifier_invalid(macos_notify_environment):\n    \"\"\"When the `terminal-notifier` program is wrongly addressed, notifications\n    should fail.\"\"\"\n\n    obj = apprise.Apprise.instantiate(\"macosx://\", suppress_exceptions=False)\n\n    # Let's disrupt the path location.\n    obj.notify_path = \"invalid_missing-file\"\n    assert not os.path.isfile(obj.notify_path)\n\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n\n\ndef test_plugin_macosx_terminal_notifier_croaks(\n    mocker, macos_notify_environment\n):\n    \"\"\"When the `terminal-notifier` program croaks on execution, notifications\n    should fail.\"\"\"\n\n    # Emulate a failing program.\n    mocker.patch(\"subprocess.Popen\", return_value=Mock(returncode=1))\n\n    obj = apprise.Apprise.instantiate(\"macosx://\", suppress_exceptions=False)\n    assert isinstance(obj, NotifyMacOSX) is True\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n\n\ndef test_plugin_macosx_pretend_linux(mocker, pretend_macos):\n    \"\"\"The notification object is disabled when pretending to run on Linux.\"\"\"\n\n    # When patching something which has a side effect on the module-level code\n    # of a plugin, make sure to reload it.\n    mocker.patch(\"platform.system\", return_value=\"Linux\")\n    reload_plugin(\"macosx\")\n\n    # Our object is disabled.\n    obj = apprise.Apprise.instantiate(\"macosx://\", suppress_exceptions=False)\n    assert obj is None\n\n\n@pytest.mark.parametrize(\"macos_version\", [\"9.12\", \"10.7\"])\ndef test_plugin_macosx_pretend_old_macos(mocker, macos_version):\n    \"\"\"The notification object is disabled when pretending to run on older\n    macOS.\"\"\"\n\n    # When patching something which has a side effect on the module-level code\n    # of a plugin, make sure to reload it.\n    mocker.patch(\n        \"platform.mac_ver\", return_value=(macos_version, (\"\", \"\", \"\"), \"\")\n    )\n    reload_plugin(\"macosx\")\n\n    obj = apprise.Apprise.instantiate(\"macosx://\", suppress_exceptions=False)\n    assert obj is None\n"
  },
  {
    "path": "tests/test_plugin_mailgun.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.mailgun import NotifyMailgun\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"mailgun://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"mailgun://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No Token specified\n    (\n        \"mailgun://user@localhost.localdomain\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Token is valid, but no user name specified\n    (\n        \"mailgun://localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid from email address\n    (\n        'mailgun://\"@localhost.localdomain/{}-{}-{}'.format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No To email address, but everything else is valid\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}?format=markdown\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}?format=html\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}?format=text\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    # valid url with region specified (case insensitve)\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}?region=uS\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    # valid url with region specified (case insensitve)\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}?region=EU\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    # invalid url with region specified (case insensitve)\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}?region=invalid\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Use of both 'name' and 'from' together; these are synonymous\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}?\"\n        \"from=jack@gmail.com&name=Jason<jason@gmail.com>\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\"instance\": NotifyMailgun},\n    ),\n    # headers\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\"\n        \"?+X-Customer-Campaign-ID=Apprise\".format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    # template tokens\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\"\n        \"?:name=Chris&:status=admin\".format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    # We can use the `from=` directive as well:\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\"\n        \"?:from=Chris&:status=admin\".format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    # bcc and cc\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\"\n        \"?bcc=user@example.com&cc=user2@example.com\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    # One To Email address\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}/test@example.com\"\n        .format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    (\n        \"mailgun://user@localhost.localdomain/\"\n        \"{}-{}-{}?to=test@example.com\".format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\"instance\": NotifyMailgun},\n    ),\n    # One To Email address, a from name specified too\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}/\"\n        'test@example.com?name=\"Frodo\"'.format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\"instance\": NotifyMailgun},\n    ),\n    # Invalid 'To' Email address\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}/invalid\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n            # Expected notify() response\n            \"notify_response\": False,\n        },\n    ),\n    # Multiple 'To', 'Cc', and 'Bcc' addresses (with invalid ones)\n    (\n        \"mailgun://user@example.com/{}-{}-{}/{}?bcc={}&cc={}\".format(\n            \"a\" * 32,\n            \"b\" * 8,\n            \"c\" * 8,\n            \"/\".join(\n                (\"user1@example.com\", \"invalid\", \"User2:user2@example.com\")\n            ),\n            \",\".join((\"user3@example.com\", \"i@v\", \"User1:user1@example.com\")),\n            \",\".join((\"user4@example.com\", \"g@r@b\", \"Da:user5@example.com\")),\n        ),\n        {\n            \"instance\": NotifyMailgun,\n        },\n    ),\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"mailgun://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifyMailgun,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_mailgun_urls():\n    \"\"\"NotifyMailgun() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_mailgun_attachments(mock_post):\n    \"\"\"NotifyMailgun() Attachments.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # API Key\n    apikey = \"abc123\"\n\n    obj = Apprise.instantiate(f\"mailgun://user@localhost.localdomain/{apikey}\")\n    assert isinstance(obj, NotifyMailgun)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    mock_post.return_value = None\n    mock_post.side_effect = OSError()\n    # We can't send the message if we can't read the attachment\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Test Valid Attachment (load 3)\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n\n    # Return our good configuration\n    mock_post.side_effect = None\n    mock_post.return_value = okay_response\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # Do it again, but fail on the third file\n    with mock.patch(\n        \"builtins.open\", side_effect=(mock.Mock(), mock.Mock(), OSError())\n    ):\n\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    with mock.patch(\"builtins.open\") as mock_open:\n        mock_fp = mock.Mock()\n        mock_fp.seek.side_effect = OSError()\n        mock_open.return_value = mock_fp\n\n        # We can't send the message we can't seek through it\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n        mock_post.reset_mock()\n        # Fail on the third file; this tests the for-loop inside the seek()\n        # section of the code that calls close() on previously opened files\n        mock_fp.seek.side_effect = (None, None, OSError())\n        mock_open.return_value = mock_fp\n        # We can't send the message we can't seek through it\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # test the handling of our batch modes\n    obj = Apprise.instantiate(\n        f\"mailgun://no-reply@example.com/{apikey}/\"\n        \"user1@example.com/user2@example.com?batch=yes\"\n    )\n    assert isinstance(obj, NotifyMailgun)\n\n    # objects will be combined into a single post in batch mode\n    assert len(obj) == 1\n\n    # Force our batch to break into separate messages\n    obj.default_batch_size = 1\n\n    # We'll send 2 messages now\n    assert len(obj) == 2\n\n    mock_post.reset_mock()\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_post.call_count == 2\n\n    # single batch\n    mock_post.reset_mock()\n    # We'll send 1 message\n    obj.default_batch_size = 2\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_mailgun_header_check(mock_post):\n    \"\"\"NotifyMailgun() Test Header Prep.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # API Key\n    apikey = \"abc123\"\n\n    obj = Apprise.instantiate(f\"mailgun://user@localhost.localdomain/{apikey}\")\n    assert isinstance(obj, NotifyMailgun)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # 2 calls were made, one to perform an email lookup, the second\n    # was the notification itself\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.mailgun.net/v3/localhost.localdomain/messages\"\n    )\n\n    payload = mock_post.call_args_list[0][1][\"data\"]\n    assert \"from\" in payload\n    assert payload[\"from\"] == \"Apprise <user@localhost.localdomain>\"\n    assert payload[\"to\"] == \"user@localhost.localdomain\"\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    obj = Apprise.instantiate(\n        f\"mailgun://user@localhost.localdomain/{apikey}?from=Luke%20Skywalker\"\n    )\n    assert isinstance(obj, NotifyMailgun)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert mock_post.call_count == 1\n    payload = mock_post.call_args_list[0][1][\"data\"]\n    assert \"from\" in payload\n    assert \"to\" in payload\n    assert payload[\"from\"] == \"Luke Skywalker <user@localhost.localdomain>\"\n    assert payload[\"to\"] == \"user@localhost.localdomain\"\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    obj = Apprise.instantiate(\n        f\"mailgun://user@localhost.localdomain/{apikey}\"\n        \"?from=Luke%20Skywalker<luke@rebels.com>\"\n    )\n    assert isinstance(obj, NotifyMailgun)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert mock_post.call_count == 1\n    payload = mock_post.call_args_list[0][1][\"data\"]\n    assert \"from\" in payload\n    assert \"to\" in payload\n    assert payload[\"from\"] == \"Luke Skywalker <luke@rebels.com>\"\n    assert payload[\"to\"] == \"luke@rebels.com\"\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    obj = Apprise.instantiate(\n        f\"mailgun://user@localhost.localdomain/{apikey}?from=luke@rebels.com\"\n    )\n    assert isinstance(obj, NotifyMailgun)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert mock_post.call_count == 1\n    payload = mock_post.call_args_list[0][1][\"data\"]\n    assert \"from\" in payload\n    assert \"to\" in payload\n    assert payload[\"from\"] == \"luke@rebels.com\"\n    assert payload[\"to\"] == \"luke@rebels.com\"\n"
  },
  {
    "path": "tests/test_plugin_mastodon.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timezone\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.mastodon import NotifyMastodon\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyMastodon\n    ##################################\n    (\n        \"mastodon://\",\n        {\n            # Missing Everything :)\n            \"instance\": None,\n        },\n    ),\n    (\n        \"mastodon://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"mastodon://hostname\",\n        {\n            # Missing Access Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"toot://access_token@hostname\",\n        {\n            # We're good; it's a simple notification\n            \"instance\": NotifyMastodon,\n        },\n    ),\n    (\n        \"toots://access_token@hostname\",\n        {\n            # We're good; it's another simple notification\n            \"instance\": NotifyMastodon,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mastodons://****@hostname/\",\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname/@user/@user2\",\n        {\n            # We're good; it's another simple notification\n            \"instance\": NotifyMastodon,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mastodon://****@hostname/@user/@user2\",\n        },\n    ),\n    (\n        \"mastodon://hostname/@user/@user2?token=abcd123\",\n        {\n            # Our access token can be provided as a token= variable\n            \"instance\": NotifyMastodon,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mastodon://****@hostname/@user/@user2\",\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname?to=@user, @user2\",\n        {\n            # We're good; it's another simple notification\n            \"instance\": NotifyMastodon,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mastodon://****@hostname/@user/@user2\",\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname/?cache=no\",\n        {\n            # disable cache as a test\n            \"instance\": NotifyMastodon,\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname/?spoiler=spoiler%20text\",\n        {\n            # a public post\n            \"instance\": NotifyMastodon,\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname/?language=en\",\n        {\n            # over-ride our language\n            \"instance\": NotifyMastodon,\n        },\n    ),\n    (\n        \"mastodons://access_token@hostname:8443\",\n        {\n            # A custom port specified\n            \"instance\": NotifyMastodon,\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname/?key=My%20Idempotency%20Key\",\n        {\n            # Prevent duplicate submissions of the same status. Idempotency\n            # keys are stored for up to 1 hour, and can be any arbitrary\n            # string. Consider using a hash or UUID generated client-side.\n            \"instance\": NotifyMastodon,\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname/-/%/\",\n        {\n            # Invalid users specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname?visibility=invalid\",\n        {\n            # An invalid visibility\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname?visibility=direct\",\n        {\n            # A direct message\n            \"instance\": NotifyMastodon,\n            # Expected notify() response False (because we won't\n            # get the response we were expecting from the upstream\n            # server\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"mastodon://access_token@hostname?visibility=direct\",\n        {\n            # A direct message\n            \"instance\": NotifyMastodon,\n            # Provide a response that allows us to look our content up\n            \"requests_response_text\": {\n                \"id\": \"12345\",\n                \"username\": \"test\",\n            },\n        },\n    ),\n    (\n        \"toots://access_token@hostname\",\n        {\n            \"instance\": NotifyMastodon,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_mastodon_urls():\n    \"\"\"NotifyMastodon() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_mastodon_general(mock_post, mock_get):\n    \"\"\"NotifyMastodon() General Tests.\"\"\"\n    token = \"access_key\"\n    host = \"nuxref.com\"\n\n    response_obj = {\n        \"username\": \"caronc\",\n        \"id\": 1234,\n    }\n\n    # Epoch time:\n    epoch = datetime.fromtimestamp(0, timezone.utc)\n\n    request = mock.Mock()\n    request.content = dumps(response_obj)\n    request.status_code = requests.codes.ok\n    request.headers = {\n        \"X-RateLimit-Limit\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n\n    # Prepare Mock\n    mock_get.return_value = request\n    mock_post.return_value = request\n\n    # Instantiate our object\n    obj = NotifyMastodon(token=token, host=host)\n\n    assert isinstance(obj, NotifyMastodon)\n    assert isinstance(obj.url(), str)\n\n    # apprise room was found\n    assert obj.send(body=\"test\") is True\n\n    # Change our status code and try again\n    request.status_code = 403\n    assert obj.send(body=\"test\") is False\n    assert obj.ratelimit_remaining == 1\n\n    # Return the status\n    request.status_code = requests.codes.ok\n    # Force a reset\n    request.headers[\"X-RateLimit-Remaining\"] = 0\n    # behind the scenes, it should cause us to update our rate limit\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n\n    # This should cause us to block\n    request.headers[\"X-RateLimit-Remaining\"] = 10\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 10\n\n    # Handle cases where we simply couldn't get this field\n    del request.headers[\"X-RateLimit-Remaining\"]\n    assert obj.send(body=\"test\") is True\n    # It remains set to the last value\n    assert obj.ratelimit_remaining == 10\n\n    # Reset our variable back to 1\n    request.headers[\"X-RateLimit-Remaining\"] = 1\n\n    # Handle cases where our epoch time is wrong\n    del request.headers[\"X-RateLimit-Limit\"]\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    request.headers[\"X-RateLimit-Limit\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds() + 1\n    request.headers[\"X-RateLimit-Remaining\"] = 0\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    request.headers[\"X-RateLimit-Limit\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds() - 1\n    request.headers[\"X-RateLimit-Remaining\"] = 0\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Return our limits to always work\n    request.headers[\"X-RateLimit-Limit\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds()\n    request.headers[\"X-RateLimit-Remaining\"] = 1\n    obj.ratelimit_remaining = 1\n\n    # Alter pending targets\n    obj.targets.append(\"usera\")\n    request.content = dumps(response_obj)\n    response_obj = {\n        \"username\": \"usera\",\n        \"id\": 4321,\n    }\n\n    # Cause content response to be None\n    request.content = None\n    assert obj.send(body=\"test\") is True\n\n    # Invalid JSON\n    request.content = \"{\"\n    assert obj.send(body=\"test\") is True\n\n    # Return it to a parseable string\n    request.content = \"{}\"\n\n    results = NotifyMastodon.parse_url(\n        f\"mastodon://{token}@{host}/@user?visbility=direct\"\n    )\n    assert isinstance(results, dict)\n    assert \"@user\" in results[\"targets\"]\n\n    # cause a json parsing issue now\n    response_obj = None\n    assert obj.send(body=\"test\") is True\n\n    response_obj = \"{\"\n    assert obj.send(body=\"test\") is True\n\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    #\n    # Test our lazy lookups\n    #\n\n    # Prepare a good status response\n    request = mock.Mock()\n    request.content = dumps({\"id\": \"1234\", \"username\": \"caronc\"})\n    request.status_code = requests.codes.ok\n    mock_get.return_value = request\n\n    mastodon_url = \"mastodons://key@host?visibility=direct\"\n    obj = Apprise.instantiate(mastodon_url)\n    obj._whoami(lazy=True)\n    assert mock_get.call_count == 1\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://host/api/v1/accounts/verify_credentials\"\n    )\n\n    mock_get.reset_mock()\n    obj._whoami(lazy=True)\n    assert mock_get.call_count == 0\n\n    mock_get.reset_mock()\n    obj._whoami(lazy=False)\n    assert mock_get.call_count == 1\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://host/api/v1/accounts/verify_credentials\"\n    )\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_mastodon_attachments(mock_get, mock_post):\n    \"\"\"NotifyMastodon() Toot Attachment Checks.\"\"\"\n    akey = \"access_key\"\n    host = \"nuxref.com\"\n    username = \"caronc\"\n\n    # Prepare a good status response\n    good_response_obj = {\n        \"id\": \"1234\",\n    }\n\n    good_response = mock.Mock()\n    good_response.content = dumps(good_response_obj)\n    good_response.status_code = requests.codes.ok\n\n    # Prepare a good whoami response\n    good_whoami_response_obj = {\n        \"username\": username,\n        \"id\": \"9876\",\n    }\n\n    good_whoami_response = mock.Mock()\n    good_whoami_response.content = dumps(good_whoami_response_obj)\n    good_whoami_response.status_code = requests.codes.ok\n\n    # Prepare bad response\n    bad_response = mock.Mock()\n    bad_response.content = dumps({})\n    bad_response.status_code = requests.codes.internal_server_error\n\n    # Prepare a good media response\n    good_media_response = mock.Mock()\n    good_media_response.content = dumps({\n        \"id\": \"710511363345354753\",\n        \"file_mime\": \"image/jpeg\",\n    })\n    good_media_response.status_code = requests.codes.ok\n\n    #\n    #  Start testing using fixtures above\n    #\n    mock_post.side_effect = [good_media_response, good_response]\n    mock_get.return_value = good_whoami_response\n\n    mastodon_url = f\"mastodon://{akey}@{host}\"\n\n    # attach our content\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # instantiate our object\n    obj = Apprise.instantiate(mastodon_url)\n\n    # Send our notification\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_get.call_count == 0\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0] == \"http://nuxref.com/api/v1/media\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"http://nuxref.com/api/v1/statuses\"\n    )\n\n    # Test our media payload\n    assert \"files\" in mock_post.call_args_list[0][1]\n    assert \"file\" in mock_post.call_args_list[0][1][\"files\"]\n    assert (\n        mock_post.call_args_list[0][1][\"files\"][\"file\"][0]\n        == \"apprise-test.gif\"\n    )\n\n    # Test our status payload\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n    assert \"status\" in payload\n    assert payload[\"status\"] == \"title\\r\\nbody\"\n    assert \"sensitive\" in payload\n    assert payload[\"sensitive\"] is False\n    assert \"media_ids\" in payload\n    assert isinstance(payload[\"media_ids\"], list)\n    assert len(payload[\"media_ids\"]) == 1\n    assert payload[\"media_ids\"][0] == \"710511363345354753\"\n\n    # Verify we don't set incorrect keys not otherwise specified\n    assert \"spoiler_text\" not in payload\n\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    #\n    # Handle the query again, but this time perform a direct message\n    # requiring us to look up who we are\n    #\n    mock_post.side_effect = [good_media_response, good_response]\n    mock_get.return_value = good_whoami_response\n\n    mastodon_url = f\"mastodon://{akey}@{host}?visibility=direct\"\n\n    # attach our content\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # instantiate our object\n    obj = Apprise.instantiate(mastodon_url)\n\n    # Send our notification\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_get.call_count == 1\n    assert mock_post.call_count == 2\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"http://nuxref.com/api/v1/accounts/verify_credentials\"\n    )\n    assert (\n        mock_post.call_args_list[0][0][0] == \"http://nuxref.com/api/v1/media\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"http://nuxref.com/api/v1/statuses\"\n    )\n\n    # Test our status payload\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n    assert \"status\" in payload\n    # Our ID was added into the payload\n    assert payload[\"status\"] == \"@caronc title\\r\\nbody\"\n    assert \"sensitive\" in payload\n    assert payload[\"sensitive\"] is False\n    assert \"media_ids\" in payload\n    assert isinstance(payload[\"media_ids\"], list)\n    assert len(payload[\"media_ids\"]) == 1\n    assert payload[\"media_ids\"][0] == \"710511363345354753\"\n\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    # Store 3 attachments\n    attach = (\n        AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")),\n        AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.png\")),\n        AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.jpeg\")),\n    )\n\n    # Prepare a good media response\n    mr1 = mock.Mock()\n    mr1.content = dumps({\n        \"id\": \"1\",\n        \"file_mime\": \"image/gif\",\n    })\n    mr1.status_code = requests.codes.ok\n\n    mr2 = mock.Mock()\n    mr2.content = dumps({\n        \"id\": \"2\",\n        \"file_mime\": \"image/png\",\n    })\n    mr2.status_code = requests.codes.ok\n\n    mr3 = mock.Mock()\n    mr3.content = dumps({\n        \"id\": \"3\",\n        \"file_mime\": \"image/jpeg\",\n    })\n    mr3.status_code = requests.codes.ok\n\n    # Return 3 good uploads and a good status response\n    mock_post.side_effect = [mr1, mr2, mr3, good_response, good_response]\n    mock_get.return_value = good_whoami_response\n\n    # instantiate our object\n    mastodon_url = (\n        f\"mastodons://{akey}@{host}?visibility=direct&sensitive=yes&key=abcd\"\n    )\n    obj = Apprise.instantiate(mastodon_url)\n\n    # Send ourselves a direct message where our ID was already found\n    # in the body.  This smart detection method will prevent us from\n    # adding the @caronc to the begining of the same message (since it's a\n    # direct message)\n    assert (\n        obj.notify(\n            body=\"Check this out @caronc\",\n            title=\"Apprise\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_get.call_count == 1\n    assert mock_post.call_count == 5\n    assert (\n        mock_post.call_args_list[0][0][0] == \"https://nuxref.com/api/v1/media\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0] == \"https://nuxref.com/api/v1/media\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0] == \"https://nuxref.com/api/v1/media\"\n    )\n    # Our status's will batch up and send the last 2 images in one\n    # and our animated gif in one.\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://nuxref.com/api/v1/statuses\"\n    )\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://nuxref.com/api/v1/statuses\"\n    )\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://nuxref.com/api/v1/accounts/verify_credentials\"\n    )\n\n    # Test our status payload\n    payload = loads(mock_post.call_args_list[3][1][\"data\"])\n    assert \"status\" in payload\n    assert payload[\"status\"] == \"Apprise\\r\\nCheck this out @caronc\"\n    assert \"sensitive\" in payload\n    assert payload[\"sensitive\"] is True\n    assert \"language\" not in payload\n    assert \"Idempotency-Key\" in payload\n    assert payload[\"Idempotency-Key\"] == \"abcd\"\n    assert \"media_ids\" in payload\n    assert isinstance(payload[\"media_ids\"], list)\n    assert len(payload[\"media_ids\"]) == 1\n    assert payload[\"media_ids\"][0] == \"1\"\n\n    payload = loads(mock_post.call_args_list[4][1][\"data\"])\n    assert \"status\" in payload\n    assert payload[\"status\"] == \"02/02\"\n    assert \"sensitive\" in payload\n    assert payload[\"sensitive\"] is False\n    assert \"language\" not in payload\n    assert \"Idempotency-Key\" in payload\n    assert payload[\"Idempotency-Key\"] == \"abcd-part01\"\n    assert \"media_ids\" in payload\n    assert isinstance(payload[\"media_ids\"], list)\n    assert len(payload[\"media_ids\"]) == 2\n    assert \"2\" in payload[\"media_ids\"]\n    assert \"3\" in payload[\"media_ids\"]\n\n    # A second call does not cause us to look up our ID as we already know it\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n    mock_post.side_effect = [mr1, mr2, mr3, good_response, good_response]\n    mock_get.return_value = good_whoami_response\n    assert (\n        obj.notify(\n            body=\"Check this out @caronc\",\n            title=\"Apprise\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Same number of posts\n    assert mock_post.call_count == 5\n    # But no lookup was made\n    assert mock_get.call_count == 0\n\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    # Prepare an attach list\n    attach = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.png\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.jpeg\"),\n    )\n\n    mock_post.side_effect = [mr2, mr3, good_response, good_response]\n    mock_get.return_value = good_whoami_response\n\n    # instantiate our object (but turn off the batch mode)\n    mastodon_url = f\"mastodons://{akey}@{host}?batch=no\"\n    obj = Apprise.instantiate(mastodon_url)\n\n    assert (\n        obj.notify(\n            body=\"Check this out @caronc\",\n            title=\"Apprise\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # 2 attachments + 2 different status messages\n    assert mock_post.call_count == 4\n\n    # But no lookup was made\n    assert mock_get.call_count == 0\n\n    assert (\n        mock_post.call_args_list[0][0][0] == \"https://nuxref.com/api/v1/media\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0] == \"https://nuxref.com/api/v1/media\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://nuxref.com/api/v1/statuses\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://nuxref.com/api/v1/statuses\"\n    )\n\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    # Prepare a bad media response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.internal_server_error\n\n    bad_responses = (\n        dumps({\"error\": \"authorized scopes\"}),\n        \"\",\n    )\n\n    #\n    # Test our Media failures\n    #\n\n    # Try several bad responses so we can capture the block of code where\n    # we try to help the end user to remind them what scope they're missing\n    for response in bad_responses:\n        mock_post.side_effect = [good_media_response, bad_response]\n        bad_response.content = response\n\n        # instantiate our object\n        mastodon_url = (\n            f\"mastodons://{akey}@{host}?visibility=public&spoiler=uhoh\"\n        )\n        obj = Apprise.instantiate(mastodon_url)\n\n        # Our notification will fail now since our toot will error out\n        # This is the same test as above, except our error response isn't\n        # parseable\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n        # Test our call count\n        assert mock_get.call_count == 0\n        assert mock_post.call_count == 2\n        assert (\n            mock_post.call_args_list[0][0][0]\n            == \"https://nuxref.com/api/v1/media\"\n        )\n        assert (\n            mock_post.call_args_list[1][0][0]\n            == \"https://nuxref.com/api/v1/media\"\n        )\n\n        mock_get.reset_mock()\n        mock_post.reset_mock()\n\n    #\n    # Test our Status failures\n    #\n\n    # Try several bad responses so we can capture the block of code where\n    # we try to help the end user to remind them what scope they're missing\n    for response in bad_responses:\n        mock_post.side_effect = [bad_response]\n        bad_response.content = response\n\n        # instantiate our object\n        mastodon_url = f\"mastodons://{akey}@{host}\"\n        obj = Apprise.instantiate(mastodon_url)\n\n        # Our notification will fail now since our toot will error out\n        # This is the same test as above, except our error response isn't\n        # parseable\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is False\n        )\n\n        # Test our call count\n        assert mock_get.call_count == 0\n        assert mock_post.call_count == 1\n        assert (\n            mock_post.call_args_list[0][0][0]\n            == \"https://nuxref.com/api/v1/statuses\"\n        )\n\n        mock_get.reset_mock()\n        mock_post.reset_mock()\n\n    #\n    # Test our whoami failures\n    #\n\n    # Try several bad responses so we can capture the block of code where\n    # we try to help the end user to remind them what scope they're missing\n    for response in bad_responses:\n        mock_get.side_effect = [bad_response]\n        bad_response.content = response\n\n        # instantiate our object\n        mastodon_url = f\"mastodons://{akey}@{host}?visibility=direct\"\n        obj = Apprise.instantiate(mastodon_url)\n\n        # Our notification will fail now since our toot will error out\n        # This is the same test as above, except our error response isn't\n        # parseable\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is False\n        )\n\n        # Test our call count\n        assert mock_get.call_count == 1\n        assert mock_post.call_count == 0\n        assert (\n            mock_get.call_args_list[0][0][0]\n            == \"https://nuxref.com/api/v1/accounts/verify_credentials\"\n        )\n\n        mock_get.reset_mock()\n        mock_post.reset_mock()\n\n    mock_post.side_effect = [mr1, mr2, mr3, good_response, good_response]\n    mock_get.return_value = None\n\n    # instantiate our object\n    mastodon_url = f\"mastodons://{akey}@{host}\"\n    obj = Apprise.instantiate(mastodon_url)\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # No get requests are made\n    assert mock_get.call_count == 0\n\n    # No post request as attachment is no good anyway\n    assert mock_post.call_count == 0\n\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    # We have an OSError thrown in the middle of our preparation\n    mock_post.side_effect = [\n        good_media_response,\n        OSError(),\n        good_media_response,\n    ]\n    mock_get.return_value = good_response\n\n    # 3 images are produced\n    attach = [\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        # This one is not supported, so it's ignored gracefully\n        os.path.join(TEST_VAR_DIR, \"apprise-archive.zip\"),\n        # A supported video file\n        os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\"),\n    ]\n\n    # We'll fail to send this time\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    assert mock_get.call_count == 0\n    # No get request as cached response is used\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0] == \"https://nuxref.com/api/v1/media\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0] == \"https://nuxref.com/api/v1/media\"\n    )\n"
  },
  {
    "path": "tests/test_plugin_matrix.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom typing import Union\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    AppriseAttachment,\n    NotifyType,\n    PersistentStoreMode,\n)\nfrom apprise.plugins.matrix import MatrixDiscoveryException, NotifyMatrix\n\nlogging.disable(logging.CRITICAL)\n\nMATRIX_GOOD_RESPONSE = dumps({\n    \"room_id\": \"!abc123:localhost\",\n    \"room_alias\": \"#abc123:localhost\",\n    \"joined_rooms\": [\"!abc123:localhost\", \"!def456:localhost\"],\n    \"access_token\": \"abcd1234\",\n    \"home_server\": \"localhost\",\n    # Simulate .well-known\n    \"m.homeserver\": {\"base_url\": \"https://matrix.example.com\"},\n    \"m.identity_server\": {\"base_url\": \"https://vector.im\"},\n})\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyMatrix\n    ##################################\n    (\n        \"matrix://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"matrixs://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"matrix://localhost?mode=off\",\n        {\n            # treats it as a anonymous user to register\n            \"instance\": NotifyMatrix,\n            # response is false because we have nothing to notify\n            \"response\": False,\n        },\n    ),\n    (\n        \"matrix://localhost\",\n        {\n            # response is TypeError because we'll try to initialize as\n            # a t2bot and fail (localhost is too short of a api key)\n            \"instance\": TypeError\n        },\n    ),\n    (\n        \"matrix://user:pass@localhost/#room1/#room2/#room3\",\n        {\n            \"instance\": NotifyMatrix,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"matrix://user:pass@localhost/#room1/#room2/!room1\",\n        {\n            \"instance\": NotifyMatrix,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"matrix://user:pass@localhost:1234/#room\",\n        {\n            \"instance\": NotifyMatrix,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"matrix://user:****@localhost:1234/\",\n        },\n    ),\n    # Matrix supports webhooks too; the following tests this now:\n    (\n        \"matrix://user:token@localhost?mode=matrix&format=text\",\n        {\n            # user and token correctly specified with webhook\n            \"instance\": NotifyMatrix,\n            \"response\": False,\n        },\n    ),\n    (\n        \"matrix://user:token@localhost?mode=matrix&format=html\",\n        {\n            # user and token correctly specified with webhook\n            \"instance\": NotifyMatrix,\n        },\n    ),\n    (\n        \"matrix://user:token@localhost:123/#general/?version=3\",\n        {\n            # Provide version over-ride (using version=)\n            \"instance\": NotifyMatrix,\n            # Our response expected server response\n            \"requests_response_text\": MATRIX_GOOD_RESPONSE,\n            \"privacy_url\": \"matrix://user:****@localhost:123\",\n        },\n    ),\n    (\n        \"matrixs://user:token@localhost/#general?v=2\",\n        {\n            # Provide version over-ride (using v=)\n            \"instance\": NotifyMatrix,\n            # Our response expected server response\n            \"requests_response_text\": MATRIX_GOOD_RESPONSE,\n            \"privacy_url\": \"matrixs://user:****@localhost\",\n        },\n    ),\n    (\n        \"matrix://user:token@localhost:123/#general/?v=invalid\",\n        {\n            # Invalid version specified\n            \"instance\": TypeError\n        },\n    ),\n    (\n        \"matrix://user:token@localhost?mode=slack&format=text\",\n        {\n            # user and token correctly specified with webhook\n            \"instance\": NotifyMatrix,\n        },\n    ),\n    (\n        \"matrixs://user:token@localhost?mode=SLACK&format=markdown\",\n        {\n            # user and token specified; slack webhook still detected\n            # despite uppercase characters\n            \"instance\": NotifyMatrix,\n        },\n    ),\n    (\n        \"matrix://user@localhost?mode=SLACK&format=markdown&token=mytoken\",\n        {\n            # user and token specified; slack webhook still detected\n            # despite uppercase characters; token also set on URL as arg\n            \"instance\": NotifyMatrix,\n        },\n    ),\n    (\n        \"matrix://_?mode=t2bot&token={}\".format(\"b\" * 64),\n        {\n            # Testing t2bot initialization and setting the password using the\n            # token directive\n            \"instance\": NotifyMatrix,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"matrix://b...b/\",\n        },\n    ),\n    # Image Reference\n    (\n        \"matrixs://user:token@localhost?mode=slack&format=markdown&image=True\",\n        {\n            # user and token specified; image set to True\n            \"instance\": NotifyMatrix,\n        },\n    ),\n    (\n        (\"matrixs://user:token@localhost?mode=slack\"\n         \"&format=markdown&image=False\"),\n        {\n            # user and token specified; image set to True\n            \"instance\": NotifyMatrix,\n        },\n    ),\n    # A Bunch of bad ports\n    (\n        \"matrixs://user:pass@hostname:port/#room_alias\",\n        {\n            # Invalid Port specified (was a string)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"matrixs://user:pass@hostname:0/#room_alias\",\n        {\n            # Invalid Port specified (was a string)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"matrixs://user:pass@hostname:65536/#room_alias\",\n        {\n            # Invalid Port specified (was a string)\n            \"instance\": TypeError,\n        },\n    ),\n    # More general testing...\n    (\n        \"matrixs://user@{}?mode=t2bot&format=markdown&image=True\".format(\n            \"a\" * 64\n        ),\n        {\n            # user and token specified; image set to True\n            \"instance\": NotifyMatrix\n        },\n    ),\n    (\n        \"matrix://user@{}?mode=t2bot&format=html&image=False\".format(\"z\" * 64),\n        {\n            # user and token specified; image set to True\n            \"instance\": NotifyMatrix\n        },\n    ),\n    # This will default to t2bot because no targets were specified and no\n    # password\n    (\n        \"matrixs://{}\".format(\"c\" * 64),\n        {\n            \"instance\": NotifyMatrix,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    # Test Native URL\n    (\n        \"https://webhooks.t2bot.io/api/v1/matrix/hook/{}/\".format(\"d\" * 64),\n        {\n            # user and token specified; image set to True\n            \"instance\": NotifyMatrix,\n        },\n    ),\n    (\n        \"matrix://user:token@localhost?mode=On\",\n        {\n            # invalid webhook specified (unexpected boolean)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"matrix://token@localhost/?mode=Matrix\",\n        {\n            \"instance\": NotifyMatrix,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"matrix://user:token@localhost/mode=matrix\",\n        {\n            \"instance\": NotifyMatrix,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"matrix://token@localhost:8080/?mode=slack\",\n        {\n            \"instance\": NotifyMatrix,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"matrix://{}/?mode=t2bot\".format(\"b\" * 64),\n        {\n            \"instance\": NotifyMatrix,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_matrix_urls():\n    \"\"\"NotifyMatrix() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_general(mock_post, mock_get, mock_put):\n    \"\"\"NotifyMatrix() General Tests.\"\"\"\n\n    response_obj = {\n        \"room_id\": \"!abc123:localhost\",\n        \"room_alias\": \"#abc123:localhost\",\n        \"joined_rooms\": [\"!abc123:localhost\", \"!def456:localhost\"],\n        \"access_token\": \"abcd1234\",\n        \"home_server\": \"localhost\",\n    }\n    request = mock.Mock()\n    request.content = dumps(response_obj)\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_get.return_value = request\n    mock_post.return_value = request\n    mock_put.return_value = request\n\n    # Variation Initializations\n    obj = NotifyMatrix(host=\"host\", targets=\"#abcd\")\n    assert isinstance(obj, NotifyMatrix)\n    assert isinstance(obj.url(), str)\n    # Registration successful\n    assert obj.send(body=\"test\") is True\n    del obj\n\n    obj = NotifyMatrix(host=\"host\", user=\"user\", targets=\"#abcd\")\n    assert isinstance(obj, NotifyMatrix)\n    assert isinstance(obj.url(), str)\n    # Registration successful\n    assert obj.send(body=\"test\") is True\n    del obj\n\n    obj = NotifyMatrix(host=\"host\", password=\"passwd\", targets=\"#abcd\")\n    assert isinstance(obj, NotifyMatrix)\n    assert isinstance(obj.url(), str)\n    # A username gets automatically generated in these cases\n    assert obj.send(body=\"test\") is True\n    del obj\n\n    obj = NotifyMatrix(\n        host=\"host\", user=\"user\", password=\"passwd\", targets=\"#abcd\"\n    )\n    assert isinstance(obj.url(), str)\n    assert isinstance(obj, NotifyMatrix)\n    # Registration Successful\n    assert obj.send(body=\"test\") is True\n    del obj\n\n    # Test sending other format types\n    kwargs = NotifyMatrix.parse_url(\n        \"matrix://user:passwd@hostname/#abcd?format=html\"\n    )\n    obj = NotifyMatrix(**kwargs)\n    assert isinstance(obj.url(), str)\n    assert isinstance(obj, NotifyMatrix)\n    assert obj.send(body=\"test\") is True\n    assert obj.send(title=\"title\", body=\"test\") is True\n    del obj\n\n    kwargs = NotifyMatrix.parse_url(\n        \"matrix://user:passwd@hostname/#abcd/#abcd:localhost?format=markdown\"\n    )\n    obj = NotifyMatrix(**kwargs)\n    assert isinstance(obj.url(), str)\n    assert isinstance(obj, NotifyMatrix)\n    assert obj.send(body=\"test\") is True\n    assert obj.send(title=\"title\", body=\"test\") is True\n    del obj\n\n    kwargs = NotifyMatrix.parse_url(\n        \"matrix://user:passwd@hostname/#abcd/!abcd:localhost?format=text\"\n    )\n    obj = NotifyMatrix(**kwargs)\n    assert isinstance(obj.url(), str)\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.send(body=\"test\") is True\n    assert obj.send(title=\"title\", body=\"test\") is True\n    del obj\n\n    # Test notice type notifications\n    kwargs = NotifyMatrix.parse_url(\n        \"matrix://user:passwd@hostname/#abcd?msgtype=notice\"\n    )\n    obj = NotifyMatrix(**kwargs)\n    assert isinstance(obj.url(), str) is True\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.send(body=\"test\") is True\n    assert obj.send(title=\"title\", body=\"test\") is True\n\n    with pytest.raises(TypeError):\n        # invalid message type specified\n        kwargs = NotifyMatrix.parse_url(\n            \"matrix://user:passwd@hostname/#abcd?msgtype=invalid\"\n        )\n        NotifyMatrix(**kwargs)\n\n    # Force a failed login\n    ro = response_obj.copy()\n    del ro[\"access_token\"]\n    request.content = dumps(ro)\n    request.status_code = 404\n\n    # Fails because we couldn't register because of 404 errors\n    assert obj.send(body=\"test\") is False\n    del obj\n\n    obj = NotifyMatrix(host=\"host\", user=\"test\", targets=\"#abcd\")\n    assert isinstance(obj, NotifyMatrix) is True\n    # Fails because we still couldn't register\n    assert obj.send(user=\"test\", password=\"passwd\", body=\"test\") is False\n    del obj\n\n    obj = NotifyMatrix(\n        host=\"host\", user=\"test\", password=\"passwd\", targets=\"#abcd\"\n    )\n    assert isinstance(obj, NotifyMatrix) is True\n    # Fails because we still couldn't register\n    assert obj.send(body=\"test\") is False\n    del obj\n\n    obj = NotifyMatrix(host=\"host\", password=\"passwd\", targets=\"#abcd\")\n    # Fails because we still couldn't register\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.send(body=\"test\") is False\n\n    # Force a empty joined list response\n    ro = response_obj.copy()\n    ro[\"joined_rooms\"] = []\n    request.content = dumps(ro)\n    assert obj.send(user=\"test\", password=\"passwd\", body=\"test\") is False\n\n    # Fall back to original template\n    request.content = dumps(response_obj)\n    request.status_code = requests.codes.ok\n\n    # update our response object so logins now succeed\n    response_obj[\"user_id\"] = \"@apprise:localhost\"\n\n    # Login was successful but not get a room_id\n    ro = response_obj.copy()\n    del ro[\"room_id\"]\n    request.content = dumps(ro)\n    assert obj.send(user=\"test\", password=\"passwd\", body=\"test\") is False\n\n    # Fall back to original template\n    request.content = dumps(response_obj)\n    request.status_code = requests.codes.ok\n    del obj\n\n    obj = NotifyMatrix(host=\"host\", targets=None)\n    assert isinstance(obj, NotifyMatrix) is True\n\n    # Force a empty joined list response\n    ro = response_obj.copy()\n    ro[\"joined_rooms\"] = []\n    request.content = dumps(ro)\n    assert obj.send(user=\"test\", password=\"passwd\", body=\"test\") is False\n\n    # Fall back to original template\n    request.content = dumps(response_obj)\n    request.status_code = requests.codes.ok\n\n    # our room list is empty so we'll have retrieved the joined_list\n    # as our backup\n    assert obj.send(user=\"test\", password=\"passwd\", body=\"test\") is True\n    del obj\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_fetch(mock_post, mock_get, mock_put):\n    \"\"\"NotifyMatrix() Server Fetch/API Tests.\"\"\"\n\n    response_obj = {\n        \"room_id\": \"!abc123:localhost\",\n        \"room_alias\": \"#abc123:localhost\",\n        \"joined_rooms\": [\"!abc123:localhost\", \"!def456:localhost\"],\n        # Login details\n        \"access_token\": \"abcd1234\",\n        \"user_id\": \"@apprise:localhost\",\n        \"home_server\": \"localhost\",\n    }\n\n    def fetch_failed(url, *args, **kwargs):\n\n        # Default configuration\n        request = mock.Mock()\n        request.status_code = requests.codes.ok\n        request.content = dumps(response_obj)\n\n        if url.find(\"/rooms/\") > -1:\n            # over-ride on room query\n            request.status_code = 403\n            request.content = dumps({\n                \"errcode\": \"M_UNKNOWN\",\n                \"error\": \"Internal server error\",\n            })\n\n        return request\n\n    mock_put.side_effect = fetch_failed\n    mock_get.side_effect = fetch_failed\n    mock_post.side_effect = fetch_failed\n\n    obj = NotifyMatrix(\n        host=\"host\", user=\"user\", password=\"passwd\", include_image=True\n    )\n    assert isinstance(obj, NotifyMatrix) is True\n    # We would hve failed to send our image notification\n    assert obj.send(user=\"test\", password=\"passwd\", body=\"test\") is False\n    del obj\n\n    # Do the same query with no images to fetch\n    asset = AppriseAsset(image_path_mask=False, image_url_mask=False)\n    obj = NotifyMatrix(\n        host=\"host\", user=\"user\", password=\"passwd\", asset=asset\n    )\n    assert isinstance(obj, NotifyMatrix) is True\n    # We would hve failed to send our notification\n    assert obj.send(user=\"test\", password=\"passwd\", body=\"test\") is False\n    del obj\n\n    response_obj = {\n        # Registration\n        \"access_token\": \"abcd1234\",\n        \"user_id\": \"@apprise:localhost\",\n        \"home_server\": \"localhost\",\n        # For room joining\n        \"room_id\": \"!abc123:localhost\",\n    }\n\n    # Default configuration\n    mock_get.side_effect = None\n    mock_post.side_effect = None\n    mock_put.side_effect = None\n\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n    request.content = dumps(response_obj)\n    mock_post.return_value = request\n    mock_get.return_value = request\n    mock_put.return_value = request\n\n    obj = NotifyMatrix(host=\"host\", include_image=True)\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n    assert obj._register() is True\n    assert obj.access_token is not None\n\n    # Cause retries\n    request.status_code = 429\n    request.content = dumps({\n        \"retry_after_ms\": 1,\n    })\n\n    postokay, _response, _ = obj._fetch(\"/retry/apprise/unit/test\")\n    assert postokay is False\n\n    request.content = dumps(\n        {\n            \"error\": {\n                \"retry_after_ms\": 1,\n            }\n        }\n    )\n    postokay, _response, _ = obj._fetch(\"/retry/apprise/unit/test\")\n    assert postokay is False\n\n    request.content = dumps({\"error\": {}})\n    postokay, _response, _ = obj._fetch(\"/retry/apprise/unit/test\")\n    assert postokay is False\n    del obj\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_auth(mock_post, mock_get, mock_put):\n    \"\"\"NotifyMatrix() Server Authentication.\"\"\"\n\n    response_obj = {\n        # Registration\n        \"access_token\": \"abcd1234\",\n        \"user_id\": \"@apprise:localhost\",\n        \"home_server\": \"localhost\",\n    }\n\n    # Default configuration\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n    request.content = dumps(response_obj)\n    mock_post.return_value = request\n    mock_get.return_value = request\n    mock_put.return_value = request\n\n    obj = NotifyMatrix(host=\"localhost\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n    # logging out without an access_token is silently a success\n    assert obj._logout() is True\n    assert obj.access_token is None\n\n    assert obj._register() is True\n    assert obj.access_token is not None\n\n    # Logging in is silently treated as a success because we\n    # already had success registering\n    assert obj._login() is True\n    assert obj.access_token is not None\n\n    # However if we log out\n    assert obj._logout() is True\n    assert obj.access_token is None\n\n    # And set ourselves up for failure\n    request.status_code = 403\n    assert obj._login() is False\n    assert obj.access_token is None\n\n    # Reset our token\n    obj.access_token = None\n\n    # Adjust our response to be invalid - missing access_token in response\n    request.status_code = requests.codes.ok\n    ro = response_obj.copy()\n    del ro[\"access_token\"]\n    request.content = dumps(ro)\n    # Our registration will fail now\n    assert obj._register() is False\n    assert obj.access_token is None\n    del obj\n\n    # So will login\n    obj = NotifyMatrix(host=\"host\", user=\"user\", password=\"password\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj._login() is False\n    assert obj.access_token is None\n\n    # Adjust our response to be invalid - invalid json response\n    request.content = \"{\"\n    # Our registration will fail now\n    assert obj._register() is False\n    assert obj.access_token is None\n\n    request.status_code = requests.codes.ok\n    request.content = dumps(response_obj)\n    assert obj._register() is True\n    assert obj.access_token is not None\n    # Test logoff when getting a 403 error\n    request.status_code = 403\n    assert obj._logout() is False\n    assert obj.access_token is not None\n\n    request.status_code = requests.codes.ok\n    request.content = dumps(response_obj)\n    assert obj._register() is True\n    assert obj.access_token is not None\n    request.status_code = 403\n    request.content = dumps({\n        \"errcode\": \"M_UNKNOWN_TOKEN\",\n        \"error\": \"Access Token unknown or expired\",\n    })\n    # Test logoff when getting a 403 error; but if we have the right error\n    # code in the response, then we return a True\n    assert obj._logout() is True\n    assert obj.access_token is None\n    del obj\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_rooms(mock_post, mock_get, mock_put):\n    \"\"\"NotifyMatrix() Room Testing.\"\"\"\n\n    response_obj = {\n        # Registration\n        \"access_token\": \"abcd1234\",\n        \"user_id\": \"@apprise:localhost\",\n        \"home_server\": \"localhost\",\n        # For joined_room response\n        \"joined_rooms\": [\"!abc123\", \"!def456:localhost\"],\n        # For room joining\n        \"room_id\": \"!abc123\",\n    }\n\n    # Default configuration\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n    request.content = dumps(response_obj)\n    mock_post.return_value = request\n    mock_get.return_value = request\n    mock_put.return_value = request\n\n    obj = NotifyMatrix(host=\"host\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    # Can't get room listing if we're not connnected\n    assert obj._room_join(\"#abc123\") is None\n\n    assert obj._register() is True\n    assert obj.access_token is not None\n\n    assert obj._room_join(\"!abc123\") == response_obj[\"room_id\"]\n    # Use cache to get same results\n    assert obj.store.get(\"!abc123\") is None\n    # However this is how the cache entry gets stored\n    assert obj.store.get(\"!abc123:localhost\") is not None\n    assert obj.store.get(\"!abc123:localhost\")[\"id\"] == response_obj[\"room_id\"]\n\n    # When hsreq=yes, legacy behaviour is restored and a homeserver is\n    # automatically appended to room IDs that omit it.\n    obj.store.clear()\n    obj.hsreq = True\n    mock_post.reset_mock()\n    assert obj._room_join(\"!abc123\") == response_obj[\"room_id\"]\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"http://host/_matrix/client/v3/join/%21abc123%3Alocalhost\"\n    )\n\n    # When hsreq=no, we honour the raw !room identifier exactly as provided\n    # and do not suffix it with :homeserver.\n    obj.store.clear()\n    obj.hsreq = False\n\n    def _join_side_effect(url, *args, **kwargs):\n        r = mock.Mock()\n\n        # With hsreq disabled, only the raw form should be attempted:\n        if url.endswith(\"/_matrix/client/v3/join/%21abc123\"):\n            r.status_code = requests.codes.ok\n            r.content = dumps(response_obj).encode(\"utf-8\")\n            return r\n\n        # Default ok for any other unexpected call\n        r.status_code = requests.codes.ok\n        r.content = dumps(response_obj).encode(\"utf-8\")\n        return r\n\n    mock_post.reset_mock()\n    mock_post.side_effect = _join_side_effect\n    assert obj._room_join(\"!abc123\") == response_obj[\"room_id\"]\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"http://host/_matrix/client/v3/join/%21abc123\"\n    )\n\n    mock_post.reset_mock()\n    assert obj._room_join(\"!abc123\") == response_obj[\"room_id\"]\n    # Cache is used\n    assert mock_post.call_count == 0\n\n    # Still using cache\n    assert obj._room_join(\"!abc123:localhost\") == response_obj[\"room_id\"]\n    assert mock_post.call_count == 0\n\n    # Toggle our settings back\n    obj.hsreq = True\n    mock_post.reset_mock()\n    mock_post.side_effect = _join_side_effect\n    assert obj._room_join(\"!abc123\") == response_obj[\"room_id\"]\n    # We still no longer need to fetch as we know the info already\n    assert mock_post.call_count == 0\n\n    # Restore defaults for remaining tests\n    mock_post.side_effect = None\n    obj.hsreq = True\n\n    # Use cache to get same results (no additional HTTP call)\n    mock_post.reset_mock()\n    assert obj._room_join(\"!abc123\") == response_obj[\"room_id\"]\n    assert mock_post.call_count == 0\n\n    obj.store.clear()\n    assert obj._room_join(\"!abc123:localhost\") == response_obj[\"room_id\"]\n    assert obj.store.get(\"!abc123:localhost\") is not None\n    assert obj.store.get(\"!abc123:localhost\")[\"id\"] == response_obj[\"room_id\"]\n    # Use cache to get same results\n    assert obj._room_join(\"!abc123:localhost\") == response_obj[\"room_id\"]\n\n    obj.store.clear()\n    assert obj._room_join(\"abc123\") == response_obj[\"room_id\"]\n    # Use cache to get same results\n    assert obj.store.get(\"#abc123:localhost\") is not None\n    assert obj.store.get(\"#abc123:localhost\")[\"id\"] == response_obj[\"room_id\"]\n    assert obj._room_join(\"abc123\") == response_obj[\"room_id\"]\n\n    obj.store.clear()\n    assert obj._room_join(\"abc123:localhost\") == response_obj[\"room_id\"]\n    # Use cache to get same results\n    assert obj.store.get(\"#abc123:localhost\") is not None\n    assert obj.store.get(\"#abc123:localhost\")[\"id\"] == response_obj[\"room_id\"]\n    assert obj._room_join(\"abc123:localhost\") == response_obj[\"room_id\"]\n\n    obj.store.clear()\n    assert obj._room_join(\"#abc123:localhost\") == response_obj[\"room_id\"]\n    # Use cache to get same results\n    assert obj.store.get(\"#abc123:localhost\") is not None\n    assert obj.store.get(\"#abc123:localhost\")[\"id\"] == response_obj[\"room_id\"]\n    assert obj._room_join(\"#abc123:localhost\") == response_obj[\"room_id\"]\n\n    obj.store.clear()\n    assert obj._room_join(\"%\") is None\n    assert obj._room_join(None) is None\n\n    # 403 response; this will push for a room creation for alias based rooms\n    # and these will fail\n    request.status_code = 403\n    obj.store.clear()\n    assert obj._room_join(\"!abc123\") is None\n    obj.store.clear()\n    assert obj._room_join(\"!abc123:localhost\") is None\n    obj.store.clear()\n    assert obj._room_join(\"abc123\") is None\n    obj.store.clear()\n    assert obj._room_join(\"abc123:localhost\") is None\n    obj.store.clear()\n    assert obj._room_join(\"#abc123:localhost\") is None\n    del obj\n\n    # Room creation\n    request.status_code = requests.codes.ok\n    obj = NotifyMatrix(host=\"host\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    # Can't get room listing if we're not connnected\n    assert obj._room_create(\"#abc123\") is None\n\n    assert obj._register() is True\n    assert obj.access_token is not None\n\n    # You can't add room_id's, they must be aliases\n    assert obj._room_create(\"!abc123\") is None\n    assert obj._room_create(\"!abc123:localhost\") is None\n    obj.store.clear()\n    assert obj._room_create(\"abc123\") == response_obj[\"room_id\"]\n    obj.store.clear()\n    assert obj._room_create(\"abc123:localhost\") == response_obj[\"room_id\"]\n    obj.store.clear()\n    assert obj._room_create(\"#abc123:localhost\") == response_obj[\"room_id\"]\n    obj.store.clear()\n    assert obj._room_create(\"%\") is None\n    assert obj._room_create(None) is None\n\n    # 403 response; this will push for a room creation for alias based rooms\n    # and these will fail\n    request.status_code = 403\n    obj.store.clear()\n    assert obj._room_create(\"abc123\") is None\n    obj.store.clear()\n    assert obj._room_create(\"abc123:localhost\") is None\n    obj.store.clear()\n    assert obj._room_create(\"#abc123:localhost\") is None\n\n    request.status_code = 403\n    request.content = dumps({\n        \"errcode\": \"M_ROOM_IN_USE\",\n        \"error\": \"Room alias already taken\",\n    })\n    obj.store.clear()\n    # This causes us to look up a channel ID if we get a ROOM_IN_USE response\n    assert obj._room_create(\"#abc123:localhost\") is None\n    del obj\n\n    # Room detection\n    request.status_code = requests.codes.ok\n    request.content = dumps(response_obj)\n    obj = NotifyMatrix(host=\"localhost\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    # No rooms if we're not connected\n    response = obj._joined_rooms()\n    assert isinstance(response, list) is True\n    assert len(response) == 0\n\n    # register our account\n    assert obj._register() is True\n    assert obj.access_token is not None\n\n    response = obj._joined_rooms()\n    assert isinstance(response, list) is True\n    assert len(response) == len(response_obj[\"joined_rooms\"])\n    for r in response:\n        assert r in response_obj[\"joined_rooms\"]\n\n    request.status_code = 403\n    response = obj._joined_rooms()\n    assert isinstance(response, list) is True\n    assert len(response) == 0\n    del obj\n\n    # Room id lookup\n    request.status_code = requests.codes.ok\n    obj = NotifyMatrix(host=\"localhost\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    # Can't get room listing if we're not connnected\n    assert obj._room_id(\"#abc123\") is None\n\n    assert obj._register() is True\n    assert obj.access_token is not None\n\n    # You can't add room_id's, they must be aliases\n    assert obj._room_id(\"!abc123\") is None\n    assert obj._room_id(\"!abc123:localhost\") is None\n    obj.store.clear()\n    assert obj._room_id(\"abc123\") == response_obj[\"room_id\"]\n    obj.store.clear()\n    assert obj._room_id(\"abc123:localhost\") == response_obj[\"room_id\"]\n    obj.store.clear()\n    assert obj._room_id(\"#abc123:localhost\") == response_obj[\"room_id\"]\n    obj.store.clear()\n    assert obj._room_id(\"%\") is None\n    assert obj._room_id(None) is None\n\n    # If we can't look the code up, we return None\n    request.status_code = 403\n    obj.store.clear()\n    assert obj._room_id(\"#abc123:localhost\") is None\n\n    # Force a object removal (thus a logout call)\n    del obj\n\n\ndef test_plugin_matrix_url_parsing():\n    \"\"\"NotifyMatrix() URL Testing.\"\"\"\n    result = NotifyMatrix.parse_url(\"matrix://user:token@localhost?to=#room\")\n    assert isinstance(result, dict) is True\n    assert len(result[\"targets\"]) == 1\n    assert \"#room\" in result[\"targets\"]\n\n    result = NotifyMatrix.parse_url(\n        \"matrix://user:token@localhost?to=#room&hsreq=yes\"\n    )\n    assert isinstance(result, dict) is True\n    assert result.get(\"hsreq\") is True\n\n    result = NotifyMatrix.parse_url(\n        \"matrix://user:token@localhost?to=#room1,#room2,#room3\"\n    )\n    assert isinstance(result, dict) is True\n    assert len(result[\"targets\"]) == 3\n    assert \"#room1\" in result[\"targets\"]\n    assert \"#room2\" in result[\"targets\"]\n    assert \"#room3\" in result[\"targets\"]\n\n    # Mixed-case alias with underscore should parse\n    result = NotifyMatrix.parse_url(\n        \"matrix://user:token@localhost?to=#Dev_Room:localhost\"\n    )\n    assert isinstance(result, dict) is True\n    assert len(result[\"targets\"]) == 1\n    assert \"#Dev_Room:localhost\" in result[\"targets\"]\n\n    # Mixed-case room id with underscore should be accepted by _room_join\n    from apprise.plugins.matrix import IS_ROOM_ID  # local alias\n    nm = NotifyMatrix(host=\"localhost\")\n    nm.access_token = \"abc\"   # simulate logged-in\n    nm.home_server = \"localhost\"\n    # this should NOT be rejected by the regex\n    assert IS_ROOM_ID.match(\"!Jm_LvU1nas_8KJPBmN9n:nginx.eu\")\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_image_errors(mock_post, mock_get, mock_put):\n    \"\"\"NotifyMatrix() Image Error Handling.\"\"\"\n\n    def mock_function_handing(url, data, **kwargs):\n        \"\"\"Dummy function for handling image posts (as a failure)\"\"\"\n        response_obj = {\n            \"room_id\": \"!abc123:localhost\",\n            \"room_alias\": \"#abc123:localhost\",\n            \"joined_rooms\": [\"!abc123:localhost\", \"!def456:localhost\"],\n            \"access_token\": \"abcd1234\",\n            \"home_server\": \"localhost\",\n        }\n\n        request = mock.Mock()\n        request.content = dumps(response_obj)\n        request.status_code = requests.codes.ok\n\n        if \"m.image\" in data:\n            # Fail for images\n            request.status_code = 400\n\n        return request\n\n    # Prepare Mock\n    mock_get.side_effect = mock_function_handing\n    mock_post.side_effect = mock_function_handing\n    mock_put.side_effect = mock_function_handing\n\n    obj = NotifyMatrix(host=\"host\", include_image=True, version=\"2\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    # Notification was successful, however we could not post image and since\n    # we had post errors (of any kind) we still report a failure.\n    assert obj.notify(\"test\", \"test\") is False\n    del obj\n\n    obj = NotifyMatrix(host=\"host\", include_image=False, version=\"2\")\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    # We didn't post an image (which was set to fail) and therefore our\n    # post was okay\n    assert obj.notify(\"test\", \"test\") is True\n\n    # Force a object removal (thus a logout call)\n    del obj\n\n    def mock_function_handing(url, data, **kwargs):\n        \"\"\"Dummy function for handling image posts (successfully)\"\"\"\n        response_obj = {\n            \"room_id\": \"!abc123:localhost\",\n            \"room_alias\": \"#abc123:localhost\",\n            \"joined_rooms\": [\"!abc123:localhost\", \"!def456:localhost\"],\n            \"access_token\": \"abcd1234\",\n            \"home_server\": \"localhost\",\n        }\n\n        request = mock.Mock()\n        request.content = dumps(response_obj)\n        request.status_code = requests.codes.ok\n\n        return request\n\n    # Prepare Mock\n    mock_get.side_effect = mock_function_handing\n    mock_put.side_effect = mock_function_handing\n    mock_post.side_effect = mock_function_handing\n    obj = NotifyMatrix(host=\"host\", include_image=True)\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    assert obj.notify(\"test\", \"test\") is True\n    del obj\n\n    obj = NotifyMatrix(host=\"host\", include_image=False)\n    assert isinstance(obj, NotifyMatrix) is True\n    assert obj.access_token is None\n\n    assert obj.notify(\"test\", \"test\") is True\n\n    # Force a object removal (thus a logout call)\n    del obj\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_attachments_api_v3(mock_post, mock_put):\n    \"\"\"NotifyMatrix() Attachment Checks (v3)\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = MATRIX_GOOD_RESPONSE.encode(\"utf-8\")\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.internal_server_error\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n    mock_put.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\"matrix://user:pass@localhost/#general?v=3\")\n\n    # attach our content\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Test our call count\n    assert mock_put.call_count == 2\n    assert mock_post.call_count == 3\n    assert mock_post.call_args_list[0][0][0] == \\\n        \"http://localhost/_matrix/client/v3/login\"\n    assert mock_post.call_args_list[1][0][0] == \\\n        \"http://localhost/_matrix/media/v3/upload\"\n    assert mock_post.call_args_list[2][0][0] == \\\n        \"http://localhost/_matrix/client/v3/join/%23general%3Alocalhost\"\n    assert mock_put.call_args_list[0][0][0] == \\\n        \"http://localhost/_matrix/client/v3/rooms/%21abc123%3Alocalhost/\" \\\n        \"send/m.room.message/0\"\n    assert mock_put.call_args_list[1][0][0] == \\\n        \"http://localhost/_matrix/client/v3/rooms/%21abc123%3Alocalhost/\" \\\n        \"send/m.room.message/1\"\n\n    # Attach a zip file type\n    attach = AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"apprise-archive.zip\")\n    )\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # update our attachment to be valid\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    mock_put.return_value = None\n    mock_post.return_value = None\n\n    # Throw an exception on the first call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        # Reset our value\n        mock_put.reset_mock()\n        mock_post.reset_mock()\n\n        mock_post.side_effect = [side_effect]\n\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Throw an exception on the second call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        # Reset our value\n        mock_put.reset_mock()\n        mock_post.reset_mock()\n\n        mock_put.side_effect = [side_effect, response]\n        mock_post.side_effect = [response, side_effect, response]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # handle a bad response\n    mock_put.side_effect = [bad_response, response]\n    mock_post.side_effect = [response, bad_response, response]\n\n    # We'll fail now because of an internal exception\n    assert obj.send(body=\"test\", attach=attach) is False\n\n    # Force a object removal (thus a logout call)\n    del obj\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_discovery_service(mock_post, mock_get):\n    \"\"\"NotifyMatrix() Discovery Service.\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = MATRIX_GOOD_RESPONSE.encode(\"utf-8\")\n\n    # Prepare a good response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.unauthorized\n    bad_response.content = MATRIX_GOOD_RESPONSE.encode(\"utf-8\")\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n    mock_get.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"matrixs://user:pass@example.com/#general?v=2&discovery=yes\"\n    )\n    assert obj.notify(\"body\") is True\n\n    response = mock.Mock()\n    response.status_code = requests.codes.unavailable\n    resp = loads(MATRIX_GOOD_RESPONSE)\n\n    mock_get.return_value = response\n    mock_post.return_value = response\n    obj = Apprise.instantiate(\n        \"matrixs://user:pass@example.com/#general?v=2&discovery=yes\"\n    )\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    # Invalid host / fallback is to resolve our own host\n    with pytest.raises(MatrixDiscoveryException):\n        _ = obj.base_url\n\n    # Verify cache is not saved\n    assert NotifyMatrix.discovery_base_key not in obj.store\n    assert NotifyMatrix.discovery_identity_key not in obj.store\n\n    response.status_code = requests.codes.ok\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    # bad data\n    resp[\"m.homeserver\"] = \"!garbage!:303\"\n    response.content = dumps(resp).encode(\"utf-8\")\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    with pytest.raises(MatrixDiscoveryException):\n        _ = obj.base_url\n\n    # Verify cache is not saved\n    assert NotifyMatrix.discovery_base_key not in obj.store\n    assert NotifyMatrix.discovery_identity_key not in obj.store\n\n    # We fail our discovery and therefore can't send our notification\n    assert obj.notify(\"hello world\") is False\n\n    # bad key\n    resp[\"m.homeserver\"] = {}\n    response.content = dumps(resp).encode(\"utf-8\")\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n    with pytest.raises(MatrixDiscoveryException):\n        _ = obj.base_url\n\n    # Verify cache is not saved\n    assert NotifyMatrix.discovery_base_key not in obj.store\n    assert NotifyMatrix.discovery_identity_key not in obj.store\n\n    resp[\"m.homeserver\"] = {\"base_url\": \"https://nuxref.com/base\"}\n    response.content = dumps(resp).encode(\"utf-8\")\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n    assert obj.base_url == \"https://nuxref.com/base\"\n    assert obj.identity_url == \"https://vector.im\"\n\n    # Verify cache saved\n    assert NotifyMatrix.discovery_base_key in obj.store\n    assert NotifyMatrix.discovery_identity_key in obj.store\n\n    # Discovery passes so notifications work too\n    assert obj.notify(\"hello world\") is True\n\n    # bad data\n    resp[\"m.identity_server\"] = \"!garbage!:303\"\n    response.content = dumps(resp).encode(\"utf-8\")\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    with pytest.raises(MatrixDiscoveryException):\n        _ = obj.base_url\n\n    # Verify cache is not saved\n    assert NotifyMatrix.discovery_base_key not in obj.store\n    assert NotifyMatrix.discovery_identity_key not in obj.store\n\n    # no key\n    resp[\"m.identity_server\"] = {}\n    response.content = dumps(resp).encode(\"utf-8\")\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    with pytest.raises(MatrixDiscoveryException):\n        _ = obj.base_url\n\n    # Verify cache is not saved\n    assert NotifyMatrix.discovery_base_key not in obj.store\n    assert NotifyMatrix.discovery_identity_key not in obj.store\n\n    # remove\n    del resp[\"m.identity_server\"]\n    response.content = dumps(resp).encode(\"utf-8\")\n\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n    assert obj.base_url == \"https://nuxref.com/base\"\n    assert obj.identity_url == \"https://nuxref.com/base\"\n\n    # restore\n    resp[\"m.identity_server\"] = {\"base_url\": '\"https://vector.im'}\n    response.content = dumps(resp).encode(\"utf-8\")\n\n    # Not found is an acceptable response (no exceptions thrown)\n    response.status_code = requests.codes.not_found\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n    assert obj.base_url == \"https://example.com\"\n    assert obj.identity_url == \"https://example.com\"\n\n    # Verify cache saved\n    assert NotifyMatrix.discovery_base_key in obj.store\n    assert NotifyMatrix.discovery_identity_key in obj.store\n\n    # Discovery passes so notifications work too\n    response.status_code = requests.codes.ok\n    assert obj.notify(\"hello world\") is True\n\n    response.status_code = requests.codes.ok\n    mock_get.return_value = None\n    mock_get.side_effect = (response, bad_response)\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    with pytest.raises(MatrixDiscoveryException):\n        _ = obj.base_url\n\n    # Verify cache is not saved\n    assert NotifyMatrix.discovery_base_key not in obj.store\n    assert NotifyMatrix.discovery_identity_key not in obj.store\n\n    # Test case where ourIdentity URI fails to do it's check\n    mock_get.side_effect = (response, response, bad_response)\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    with pytest.raises(MatrixDiscoveryException):\n        _ = obj.base_url\n\n    # Verify cache is not saved\n    assert NotifyMatrix.discovery_base_key not in obj.store\n    assert NotifyMatrix.discovery_identity_key not in obj.store\n\n    # Test an empty block response\n    response.status_code = requests.codes.ok\n    response.content = \"\"\n    mock_get.return_value = response\n    mock_get.side_effect = None\n    mock_post.return_value = response\n    mock_post.side_effect = None\n    obj.store.clear(\n        NotifyMatrix.discovery_base_key, NotifyMatrix.discovery_identity_key\n    )\n\n    assert obj.base_url == \"https://example.com\"\n    assert obj.identity_url == \"https://example.com\"\n\n    # Verify cache saved\n    assert NotifyMatrix.discovery_base_key in obj.store\n    assert NotifyMatrix.discovery_identity_key in obj.store\n\n    del obj\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_attachments_api_v2(mock_post, mock_get):\n    \"\"\"NotifyMatrix() Attachment Checks (v2)\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = MATRIX_GOOD_RESPONSE.encode(\"utf-8\")\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.internal_server_error\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n    mock_get.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\"matrix://user:pass@localhost/#general?v=2\")\n\n    # attach our content\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Attach an unsupported file\n    mock_post.return_value = response\n    mock_get.return_value = response\n    mock_post.side_effect = None\n    mock_get.side_effect = None\n\n    # Force a object removal (thus a logout call)\n    del obj\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\"matrixs://user:pass@localhost/#general?v=2\")\n\n    # Reset our object\n    mock_post.reset_mock()\n    mock_get.reset_mock()\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 5\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://matrix.example.com/_matrix/client/r0/login\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://matrix.example.com/_matrix/media/r0/upload\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://matrix.example.com/_matrix/client/r0/\"\n        \"join/%23general%3Alocalhost\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://matrix.example.com/_matrix/client/r0\"\n        \"/rooms/%21abc123%3Alocalhost/send/m.room.message\"\n    )\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://matrix.example.com/_matrix/client/r0/\"\n        \"rooms/%21abc123%3Alocalhost/send/m.room.message\"\n    )\n\n    # Attach an unsupported file type; these are skipped\n    attach = AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"apprise-archive.zip\")\n    )\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # update our attachment to be valid\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    mock_post.return_value = None\n    mock_get.return_value = None\n\n    # Throw an exception on the first call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        # Reset our value\n        mock_post.reset_mock()\n        mock_get.reset_mock()\n\n        mock_post.side_effect = [side_effect, response]\n        mock_get.side_effect = [side_effect, response]\n\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Throw an exception on the second call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        # Reset our value\n        mock_post.reset_mock()\n        mock_get.reset_mock()\n\n        mock_post.side_effect = [response, side_effect, side_effect, response]\n        mock_get.side_effect = [side_effect, side_effect, response]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # handle a bad response\n    mock_post.side_effect = [\n        response,\n        bad_response,\n        response,\n        response,\n        response,\n        response,\n    ]\n    mock_get.side_effect = [\n        response,\n        bad_response,\n        response,\n        response,\n        response,\n        response,\n    ]\n\n    # We'll fail now because of an internal exception\n    assert obj.send(body=\"test\", attach=attach) is False\n\n    # Force a object removal (thus a logout call)\n    del obj\n\n    # Instantiate our object (no discovery required)\n    obj = Apprise.instantiate(\n        \"matrixs://user:pass@localhost/#general?v=2&discovery=no&image=y\"\n    )\n\n    # Reset our object\n    mock_post.reset_mock()\n    mock_get.reset_mock()\n\n    mock_post.return_value = None\n    mock_get.return_value = None\n    mock_post.side_effect = [\n        response,\n        response,\n        bad_response,\n        response,\n        response,\n        response,\n        response,\n    ]\n    mock_get.side_effect = [\n        response,\n        response,\n        bad_response,\n        response,\n        response,\n        response,\n        response,\n    ]\n\n    # image attachment didn't succeed\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n    # Error during image post\n    mock_post.return_value = response\n    mock_get.return_value = response\n    mock_post.side_effect = None\n    mock_get.side_effect = None\n\n    # We'll fail now because of an internal exception\n    assert obj.send(body=\"test\", attach=attach) is True\n\n    # Force __del__() call\n    del obj\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_transaction_ids_api_v3_no_cache(\n    mock_post, mock_get, mock_put\n):\n    \"\"\"NotifyMatrix() Transaction ID Checks (v3)\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = MATRIX_GOOD_RESPONSE.encode(\"utf-8\")\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.internal_server_error\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n    mock_get.return_value = response\n    mock_put.return_value = response\n\n    # For each element is 1 batch that is ran\n    # the number defined is the number of notifications to send\n    batch = [10, 1, 5]\n\n    for notifications in batch:\n        # Instantiate our object\n        obj = Apprise.instantiate(\"matrix://user:pass@localhost/#general?v=3\")\n\n        # Ensure mode is memory\n        assert obj.store.mode == PersistentStoreMode.MEMORY\n\n        # Performs a login\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is True\n        )\n        assert mock_get.call_count == 0\n        assert mock_post.call_count == 2\n        assert (\n            mock_post.call_args_list[0][0][0]\n            == \"http://localhost/_matrix/client/v3/login\"\n        )\n        assert (\n            mock_post.call_args_list[1][0][0]\n            == \"http://localhost/_matrix/client/v3/join/%23general%3Alocalhost\"\n        )\n        assert mock_put.call_count == 1\n        assert (\n            mock_put.call_args_list[0][0][0]\n            == \"http://localhost/_matrix/client/v3/rooms/\"\n            + \"%21abc123%3Alocalhost/send/m.room.message/0\"\n        )\n\n        for no, _ in enumerate(range(notifications), start=1):\n            # Clean our slate\n            mock_post.reset_mock()\n            mock_get.reset_mock()\n            mock_put.reset_mock()\n\n            assert (\n                obj.notify(\n                    body=\"body\", title=\"title\", notify_type=NotifyType.INFO\n                )\n                is True\n            )\n\n            assert mock_get.call_count == 0\n            assert mock_post.call_count == 0\n            assert mock_put.call_count == 1\n            assert (\n                mock_put.call_args_list[0][0][0]\n                == \"http://localhost/_matrix/client/v3/rooms/\"\n                + f\"%21abc123%3Alocalhost/send/m.room.message/{no}\"\n            )\n\n        mock_post.reset_mock()\n        mock_get.reset_mock()\n        mock_put.reset_mock()\n\n        # Force a object removal (thus a logout call)\n        del obj\n\n        assert mock_get.call_count == 0\n        assert mock_post.call_count == 1\n        assert (\n            mock_post.call_args_list[0][0][0]\n            == \"http://localhost/_matrix/client/v3/logout\"\n        )\n        mock_post.reset_mock()\n        assert mock_put.call_count == 0\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_transaction_ids_api_v3_w_cache(\n    mock_post, mock_get, mock_put, tmpdir\n):\n    \"\"\"NotifyMatrix() Transaction ID Checks (v3)\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = MATRIX_GOOD_RESPONSE.encode(\"utf-8\")\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.status_code = requests.codes.internal_server_error\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n    mock_get.return_value = response\n    mock_put.return_value = response\n\n    # For each element is 1 batch that is ran\n    # the number defined is the number of notifications to send\n    batch = [10, 1, 5]\n\n    mock_post.reset_mock()\n    mock_get.reset_mock()\n    mock_put.reset_mock()\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir),\n    )\n\n    # Message Counter\n    transaction_id = 1\n\n    for no, notifications in enumerate(batch):\n        # Instantiate our object\n        obj = Apprise.instantiate(\n            \"matrix://user:pass@localhost/#general?v=3\", asset=asset\n        )\n\n        # Ensure mode is flush\n        assert obj.store.mode == PersistentStoreMode.FLUSH\n\n        # Performs a login\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is True\n        )\n        assert mock_get.call_count == 0\n        if no == 0:\n            # first entry\n            assert mock_post.call_count == 2\n            assert (\n                mock_post.call_args_list[0][0][0]\n                == \"http://localhost/_matrix/client/v3/login\"\n            )\n            assert (\n                mock_post.call_args_list[1][0][0]\n                == \"http://localhost/_matrix/client/v3/\"\n                \"join/%23general%3Alocalhost\"\n            )\n            assert mock_put.call_count == 1\n            assert (\n                mock_put.call_args_list[0][0][0]\n                == \"http://localhost/_matrix/client/v3/rooms/\"\n                + \"%21abc123%3Alocalhost/send/m.room.message/0\"\n            )\n\n        for no, _ in enumerate(range(notifications), start=transaction_id):\n            # Clean our slate\n            mock_post.reset_mock()\n            mock_get.reset_mock()\n            mock_put.reset_mock()\n\n            assert (\n                obj.notify(\n                    body=\"body\", title=\"title\", notify_type=NotifyType.INFO\n                )\n                is True\n            )\n\n            # Increment transaction counter\n            transaction_id += 1\n\n            assert mock_get.call_count == 0\n            assert mock_post.call_count == 0\n            assert mock_put.call_count == 1\n            assert (\n                mock_put.call_args_list[0][0][0]\n                == \"http://localhost/_matrix/client/v3/rooms/\"\n                + f\"%21abc123%3Alocalhost/send/m.room.message/{no}\"\n            )\n\n        # Increment transaction counter\n        transaction_id += 1\n\n        mock_post.reset_mock()\n        mock_get.reset_mock()\n        mock_put.reset_mock()\n\n        # Force a object removal\n        # Biggest takeaway is that a logout no longer happens\n        del obj\n\n        assert mock_get.call_count == 0\n        assert mock_post.call_count == 0\n        assert mock_put.call_count == 0\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_v3_url_with_port_assembly(\n    mock_post, mock_get, mock_put, tmpdir\n):\n    \"\"\"NotifyMatrix() URL with Port Assembly Checks (v3)\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = MATRIX_GOOD_RESPONSE.encode(\"utf-8\")\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n    mock_get.return_value = response\n    mock_put.return_value = response\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir),\n    )\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"matrixs://user1:pass123@example.ca:8080/#general?v=3\", asset=asset\n    )\n    # Performs a login\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Secure Connections have a bit of additional overhead to verify\n    # the authenticity of the server through discovery\n    assert mock_get.call_count == 3\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://example.ca:8080/.well-known/matrix/client\"\n    )\n    assert (\n        mock_get.call_args_list[1][0][0]\n        == \"https://matrix.example.com/_matrix/client/versions\"\n    )\n    assert (\n        mock_get.call_args_list[2][0][0]\n        == \"https://vector.im/_matrix/identity/v2\"\n    )\n\n    assert mock_post.call_count == 2\n    # matrix.example.com comes from our MATRIX_GOOD_RESPONSE\n    # response which defines wht our .well-known returned to us\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://matrix.example.com/_matrix/client/v3/login\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://matrix.example.com/_matrix/client/v3/\"\n        \"join/%23general%3Alocalhost\"\n    )\n    assert mock_put.call_count == 1\n    assert (\n        mock_put.call_args_list[0][0][0]\n        == \"https://matrix.example.com/_matrix/client/v3/rooms/\"\n        + \"%21abc123%3Alocalhost/send/m.room.message/0\"\n    )\n\n    mock_post.reset_mock()\n    mock_get.reset_mock()\n    mock_put.reset_mock()\n\n    assert obj.base_url == \"https://matrix.example.com\"\n\n    # Cache is used under the hood; no second discover is performed\n    assert mock_put.call_count == 0\n    assert mock_get.call_count == 0\n    assert mock_post.call_count == 0\n\n    # Cleanup\n    del obj\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_no_room_create_on_non_not_found_join(\n    mock_post: mock.Mock,\n    mock_get: mock.Mock,\n    mock_put: mock.Mock,\n) -> None:\n    \"\"\"No room creation when join fails with 400 (or other non-404).\n\n    A join failure can occur for many reasons, such as auth failure or invite\n    required. In these cases, attempting to create the room is incorrect and\n    produces misleading follow-up errors like M_ROOM_IN_USE.\n    \"\"\"\n\n    def _resp(status_code: int, content: Union[str, bytes]) -> mock.Mock:\n        r = mock.Mock()\n        r.status_code = status_code\n        if isinstance(content, str):\n            r.content = content.encode(\"utf-8\")\n        else:\n            r.content = content\n        return r\n\n    login = dumps(\n        {\n            \"access_token\": \"t\",\n            \"home_server\": \"hs\",\n            \"user_id\": \"@u:hs\",\n        }\n    )\n\n    # POST sequence:\n    # 1. /login succeeds\n    # 2. /join/#backup fails with 400 and an empty body\n    # 3. /logout succeeds during object cleanup remembering token\n    mock_post.side_effect = [\n        _resp(requests.codes.ok, login),\n        _resp(requests.codes.bad_request, b\"\"),\n        _resp(requests.codes.ok, dumps({})),\n    ]\n\n    # No other requests should be needed (no createRoom, no directory lookup)\n    mock_get.return_value = _resp(requests.codes.internal_server_error, b\"\")\n    mock_put.return_value = _resp(requests.codes.ok, dumps({}))\n\n    ap = Apprise()\n    ap.add(\"matrixs://user:pass@matrix.vip/#backup?discovery=no\")\n\n    assert ap.notify(title=\"t\", body=\"b\") is False\n\n    # Cleanup explicitly to ensure __del__ executes while mocks are active.\n    import gc\n\n    del ap\n    gc.collect()\n\n    # login + join + logout\n    assert mock_post.call_count == 3\n    assert mock_get.call_count == 0\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_matrix_room_create_on_not_found_join(\n    mock_post: mock.Mock,\n    mock_get: mock.Mock,\n    mock_put: mock.Mock,\n) -> None:\n    \"\"\"Attempt room creation only when join reports 404 / M_NOT_FOUND.\"\"\"\n\n    def _resp(status_code: int, content: Union[str, bytes]) -> mock.Mock:\n        r = mock.Mock()\n        r.status_code = status_code\n        if isinstance(content, str):\n            r.content = content.encode(\"utf-8\")\n        else:\n            r.content = content\n        return r\n\n    login = dumps(\n        {\n            \"access_token\": \"t\",\n            \"home_server\": \"hs\",\n            \"user_id\": \"@u:hs\",\n        }\n    )\n\n    # POST sequence:\n    # 1. /login succeeds\n    # 2. /join/#backup returns 404 not found\n    # 3. /createRoom returns alias in use, triggering alias resolution\n    # 4. /logout succeeds during object cleanup\n    mock_post.side_effect = [\n        _resp(requests.codes.ok, login),\n        _resp(\n            requests.codes.not_found,\n            dumps({\"errcode\": \"M_NOT_FOUND\", \"error\": \"Not found\"}),\n        ),\n        _resp(\n            requests.codes.bad_request,\n            dumps(\n                {\n                    \"errcode\": \"M_ROOM_IN_USE\",\n                    \"error\": \"Room alias already taken\",\n                }\n            ),\n        ),\n        _resp(requests.codes.ok, dumps({})),\n    ]\n\n    # Directory lookup returns the room id\n    mock_get.return_value = _resp(\n        requests.codes.ok,\n        dumps({\"room_id\": \"!abc123:matrix.vip\"}),\n    )\n\n    # Sending the message succeeds (v3 uses PUT)\n    mock_put.return_value = _resp(requests.codes.ok, dumps({}))\n\n    ap = Apprise()\n    ap.add(\"matrixs://user:pass@matrix.vip/#backup?discovery=no\")\n\n    assert ap.notify(title=\"t\", body=\"b\") is True\n\n    import gc\n\n    del ap\n    gc.collect()\n\n    # login + join + createRoom + logout\n    assert mock_post.call_count == 4\n    assert mock_get.call_count == 1\n    assert mock_put.call_count == 1\n"
  },
  {
    "path": "tests/test_plugin_mattermost.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.mattermost import MattermostMode, NotifyMattermost\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\"mmost://\", {\"instance\": None}),\n    (\"mmosts://\", {\"instance\": None}),\n    (\"mmost://:@/\", {\"instance\": None}),\n    (\n        \"mmosts://localhost\",\n        {\n            # Thrown because there was no webhook id specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        (\n            \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?icon_url=http://localhost/test.png\"\n        ),\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        \"mmost://user@localhost/3ccdd113474722377935511fc85d3dd4?channel=test\",\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        (\n            \"mmost://user@localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?channels=test\"\n        ),\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        \"mmost://user@localhost/3ccdd113474722377935511fc85d3dd4?to=test\",\n        {\n            \"instance\": NotifyMattermost,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mmost://user@localhost/3...4/\",\n        },\n    ),\n    (\n        (\n            \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?to=test&image=True\"\n        ),\n        {\"instance\": NotifyMattermost},\n    ),\n    (\n        (\n            # Team defined implies bot mode\n            \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?to=test&team=chester\"\n        ),\n        {\"instance\": NotifyMattermost},\n    ),\n    (\n        (\n            # sets botname on webhook\n            \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?to=general&botname=foobar\"\n        ),\n        {\"instance\": NotifyMattermost},\n    ),\n    (\n        (\n            \"mmost://team@localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?channel=$!garbag3^&mode=bot\"\n        ),\n        {\n            \"instance\": NotifyMattermost,\n            # We will fail to notify anyone due to the bad entry\n            \"notify_response\": False,\n        },\n    ),\n    (\n        (\n            \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?to=test&image=False\"\n        ),\n        {\"instance\": NotifyMattermost},\n    ),\n    (\n        (\n            \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\"\n            \"?to=test&image=True\"\n        ),\n        {\n            \"instance\": NotifyMattermost,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"mmost://localhost:8080/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mmost://localhost:8080/3...4/\",\n        },\n    ),\n    (\n        \"mmost://localhost:8080/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        \"mmost://localhost:invalid-port/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"mmosts://localhost/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        (\n            \"https://mattermost.example.com/hooks/\"\n            \"3ccdd113474722377935511fc85d3dd4\"\n        ),\n        {\n            \"instance\": NotifyMattermost,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"mmosts://mattermost.example.com/3...4/\",\n        },\n    ),\n    # Test our paths\n    (\"mmosts://localhost/a/path/3ccdd113474722377935511fc85d3dd4\", {\n        \"instance\": NotifyMattermost}),\n    (\"mmosts://localhost/////3ccdd113474722377935511fc85d3dd4///\", {\n        \"instance\": NotifyMattermost}),\n\n    # Mode parsing (prefix support)\n    (\"mmost://localhost/token?mode=w\", {\"instance\": NotifyMattermost}),\n    (\"mmost://localhost/token?mode=b&to=channel-id-1\", {\n        \"instance\": NotifyMattermost}),\n    (\n        \"mmost://localhost/token?mode=invalid\",\n        {\n            # invalid mode is detected in __init__\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"mmosts://localhost/a/path/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        \"mmosts://localhost/////3ccdd113474722377935511fc85d3dd4///\",\n        {\n            \"instance\": NotifyMattermost,\n        },\n    ),\n    (\n        \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"mmost://localhost/3ccdd113474722377935511fc85d3dd4\",\n        {\n            \"instance\": NotifyMattermost,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\n@pytest.fixture\ndef request_get_mock(mocker):\n    \"\"\"Prepare requests.get mock.\"\"\"\n    mock_get = mocker.patch(\"requests.get\")\n    mock_get.return_value = requests.Request()\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_get.return_value.content = b'{\"id\":\"abc123\"}'\n    return mock_get\n\n\n@pytest.fixture\ndef request_post_mock(mocker):\n    \"\"\"Prepare requests mock.\"\"\"\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = b\"\"\n    return mock_post\n\n\ndef test_plugin_mattermost_urls():\n    \"\"\"NotifyMattermost() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_mattermost_edge_cases():\n    \"\"\"NotifyMattermost() Edge Cases.\"\"\"\n\n    # Invalid Authorization Token\n    with pytest.raises(TypeError):\n        NotifyMattermost(None)\n    with pytest.raises(TypeError):\n        NotifyMattermost(\"     \")\n\n\ndef test_plugin_mattermost_len_webhook_and_bot(\n        request_post_mock, request_get_mock):\n    \"\"\"NotifyMattermost() __len__() behaviour.\"\"\"\n    # webhook: no channels -> 1\n    obj = Apprise.instantiate(\"mmost://localhost/token\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.WEBHOOK\n    assert len(obj) == 1\n\n    # webhook: channels -> count\n    obj = Apprise.instantiate(\"mmost://localhost/token?channels=a,b,c\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.WEBHOOK\n    assert len(obj) == 3\n\n    # bot: channels are channel_id values -> count\n    obj = Apprise.instantiate(\"mmost://localhost/token?mode=bot&to=id1,id2\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n    assert len(obj) == 2\n    assert obj.notify(\"test\") is True\n    assert \"mode=bot\" in obj.url()\n\n    # bot: channels are channel_id values -> count\n    obj = Apprise.instantiate(\"mmost://localhost/token?mode=bot&to=#chan,id1\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n    # our #chan isn't added to the list because no team was provided\n    assert len(obj) == 1\n    assert obj.notify(\"test\") is True\n    assert \"mode=bot\" in obj.url()\n\n    obj = Apprise.instantiate(\"mmost://localhost/token?mode=bot&to=#chan\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n    # No one to notify (but we always return a minimum of 1)\n    assert len(obj) == 1\n    assert obj.notify(\"test\") is False\n    assert \"mode=bot\" in obj.url()\n\n    obj = Apprise.instantiate(\n        \"mmost://team@localhost/token?mode=bot&to=#chan,id1\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n    assert len(obj) == 2\n    # We can look up the team now\n    assert obj.notify(\"test\") is True\n    assert \"mode=bot\" in obj.url()\n    # Second call to notify() pulls from cache\n    assert obj.notify(\"test\") is True\n\n    obj = Apprise.instantiate(\n        \"mmost://team@localhost/token?mode=bot&to=#chan,id1\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n    # Invalid response\n    request_get_mock.return_value.content = b\"}\"\n    assert obj.notify(\"test\") is False\n\n    obj = Apprise.instantiate(\n        \"mmost://team@localhost/token?mode=bot&to=#chan,id1\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n    # empty response\n    request_get_mock.return_value.content = b\"{}\"\n    assert obj.notify(\"test\") is False\n\n    obj = Apprise.instantiate(\n        \"mmost://team@localhost/token?mode=bot&to=#chan,id1\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n    # upstream inquiry failure\n    request_get_mock.side_effect = requests.RequestException\n    assert obj.notify(\"test\") is False\n\n\ndef test_plugin_mattermost_channels(request_post_mock):\n    \"\"\"NotifyMattermost() Channel Testing.\"\"\"\n\n    # Test channels with/without hashtag (#)\n    user = \"user1\"\n    token = \"token\"\n    channels = [\"#one\", \"two\"]\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"mmost://{user}@localhost:8065/{token}?channels={channels}\".format(\n            user=user, token=token, channels=\",\".join(channels)\n        )\n    )\n\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.notify(body=\"body\", title=\"title\") is True\n\n    assert request_post_mock.called is True\n    assert request_post_mock.call_count == 2\n    assert request_post_mock.call_args_list[0][0][0].startswith(\n        \"http://localhost:8065/hooks/token\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert \"username\" in posted_json\n    assert \"channel\" in posted_json\n    assert \"text\" in posted_json\n    assert posted_json[\"username\"] == \"user1\"\n    assert posted_json[\"channel\"] == \"one\"\n    assert posted_json[\"text\"] == \"title\\r\\nbody\"\n\n    # Our second Posted JSON Object\n    posted_json = json.loads(request_post_mock.call_args_list[1][1][\"data\"])\n    assert posted_json[\"username\"] == \"user1\"\n    assert posted_json[\"channel\"] == \"two\"\n    assert posted_json[\"text\"] == \"title\\r\\nbody\"\n\n\ndef test_mattermost_post_default_port(request_post_mock):\n    # Test token\n    token = \"token\"\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(f\"mmosts://mattermost.example.com/{token}\")\n\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.notify(body=\"body\", title=\"title\") is True\n\n    # Make sure we don't use port if not provided\n    assert request_post_mock.called is True\n    assert request_post_mock.call_count == 1\n    assert request_post_mock.call_args_list[0][0][0].startswith(\n        \"https://mattermost.example.com/hooks/token\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert \"text\" in posted_json\n    assert posted_json[\"text\"] == \"title\\r\\nbody\"\n\n\ndef test_mattermost_icon_override(request_post_mock):\n    # Test token\n    token = \"token\"\n    icon_url = \"http://localhost/test.png\"\n\n    # Instantiate our URL with an icon override\n    obj = Apprise.instantiate(\n        \"mmost://mattermost.example.com/{token}?icon_url={icon_url}\".format(\n            token=token,\n            icon_url=icon_url,\n        )\n    )\n\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.notify(body=\"body\", title=\"title\") is True\n\n    assert request_post_mock.called is True\n    assert request_post_mock.call_count == 1\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert posted_json[\"icon_url\"] == icon_url\n\n\ndef test_plugin_mattermost_webhook_payload_variants(request_post_mock, mocker):\n    \"\"\"Webhook mode covers icon_url vs include_image branches, and optional\n    channel.\"\"\"\n    # Force image_url() to be deterministic for coverage\n    mocker.patch.object(\n        NotifyMattermost, \"image_url\", return_value=\"http://img/ok.png\")\n\n    # Case 1: include_image=True (default) and no icon_url -> icon_url from\n    obj = Apprise.instantiate(\"mmost://user@localhost/token?to=test\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.WEBHOOK\n    assert obj.notify(body=\"body\", title=\"title\") is True\n\n    posted_json = json.loads(request_post_mock.call_args_list[-1][1][\"data\"])\n    assert posted_json[\"username\"] == \"user\"\n    assert posted_json[\"channel\"] == \"test\"\n    assert posted_json[\"text\"] == \"title\\r\\nbody\"\n    assert posted_json[\"icon_url\"] == \"http://img/ok.png\"\n\n    # Case 2: icon_url overrides include_image\n    request_post_mock.reset_mock()\n    obj = Apprise.instantiate(\n        \"mmost://user@localhost/token?to=test&icon_url=http://x/icon.png\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.notify(body=\"body\", title=\"title\") is True\n\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert posted_json[\"icon_url\"] == \"http://x/icon.png\"\n\n    # Case 3: no channels specified -> payload has no channel key\n    request_post_mock.reset_mock()\n    obj = Apprise.instantiate(\"mmost://user@localhost/token\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.notify(body=\"body\", title=\"title\") is True\n\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert \"channel\" not in posted_json\n\n\ndef test_plugin_mattermost_webhook_http_error_and_exception(\n        request_post_mock, mocker):\n    \"\"\"Webhook mode error paths.\"\"\"\n    obj = Apprise.instantiate(\"mmost://localhost/token?to=test\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.WEBHOOK\n\n    # HTTP error\n    request_post_mock.return_value.status_code = requests.codes.bad_request\n    assert obj.notify(body=\"body\", title=\"title\") is False\n\n    # Request exception\n    request_post_mock.reset_mock()\n    request_post_mock.side_effect = requests.RequestException(\"boom\")\n    assert obj.notify(body=\"body\", title=\"title\") is False\n\n\ndef test_plugin_mattermost_bot_mode_success_and_payload(request_post_mock):\n    \"\"\"Bot mode success path, headers and payload.\"\"\"\n    bearer = \"bearerToken\"\n    channel_id = \"channel-id-123\"\n\n    obj = Apprise.instantiate(\n        f\"mmosts://mattermost.example.com/{bearer}?mode=bot&to={channel_id}\"\n    )\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n\n    # Mattermost returns 201 Created for /api/v4/posts\n    request_post_mock.return_value.status_code = requests.codes.created\n\n    assert obj.notify(body=\"body\", title=\"title\") is True\n    assert request_post_mock.called is True\n    assert request_post_mock.call_count == 1\n\n    url = request_post_mock.call_args_list[0][0][0]\n    assert url.startswith(\"https://mattermost.example.com/api/v4/posts\")\n\n    headers = request_post_mock.call_args_list[0][1][\"headers\"]\n    assert headers.get(\"Authorization\") == f\"Bearer {bearer}\"\n\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert posted_json[\"channel_id\"] == channel_id\n    assert posted_json[\"message\"] == \"title\\r\\nbody\"\n    assert \"text\" not in posted_json\n\n\ndef test_plugin_mattermost_bot_mode_requires_channel_id(request_post_mock):\n    \"\"\"Bot mode requires at least one channel_id target.\"\"\"\n    bearer = \"bearerToken\"\n    obj = Apprise.instantiate(f\"mmost://localhost/{bearer}?mode=bot\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n\n    request_post_mock.return_value.status_code = requests.codes.created\n    assert obj.notify(body=\"body\", title=\"title\") is False\n    assert request_post_mock.called is False\n\n\ndef test_plugin_mattermost_bot_mode_http_error_and_exception(\n        request_post_mock):\n    \"\"\"Bot mode error paths.\"\"\"\n    bearer = \"bearerToken\"\n    obj = Apprise.instantiate(f\"mmost://localhost/{bearer}?mode=bot&to=id1\")\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n\n    # HTTP error (not 201)\n    request_post_mock.return_value.status_code = requests.codes.unauthorized\n    assert obj.notify(body=\"body\", title=\"title\") is False\n\n    # Request exception\n    request_post_mock.reset_mock()\n    request_post_mock.side_effect = requests.RequestException(\"boom\")\n    assert obj.notify(body=\"body\", title=\"title\") is False\n\n\ndef test_plugin_mattermost_bot_channel_lookup_success(\n        request_post_mock, request_get_mock):\n    \"\"\"Bot mode resolves #channel via team lookup.\"\"\"\n    bearer = \"bearerToken\"\n    team = \"myteam\"\n    channel = \"#general\"\n    channel_id = \"cid123\"\n\n    # Mock channel lookup response\n    request_get_mock.return_value.content = json.dumps(\n        {\"id\": channel_id}\n    ).encode(\"utf-8\")\n\n    obj = Apprise.instantiate(\n        \"mmost://localhost/{bearer}?mode=bot&team={team}&to={chan}\"\n        .format(bearer=bearer, team=team, chan=channel)\n    )\n    assert isinstance(obj, NotifyMattermost)\n    assert obj.mode == MattermostMode.BOT\n\n    request_post_mock.return_value.status_code = requests.codes.created\n    assert obj.notify(body=\"body\", title=\"title\") is True\n\n    assert request_get_mock.called is True\n    get_url = request_get_mock.call_args_list[0][0][0]\n    assert \"/api/v4/teams/name/\" in get_url\n    assert \"/channels/name/\" in get_url\n\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert posted_json[\"channel_id\"] == channel_id\n    assert posted_json[\"message\"] == \"title\\r\\nbody\"\n\n\ndef test_plugin_mattermost_bot_channel_lookup_partial_success(\n        request_post_mock, request_get_mock):\n    \"\"\"One lookup fails, one succeeds, overall result is False.\"\"\"\n    bearer = \"bearerToken\"\n    team = \"myteam\"\n\n    def side_effect(*args, **kwargs):\n        r = requests.Request()\n        url = args[0]\n        if url.endswith(\"/channels/name/good\"):\n            r.status_code = requests.codes.ok\n            r.content = b'{\"id\":\"cid-good\"}'\n        else:\n            r.status_code = requests.codes.not_found\n            r.content = b\"\"\n        return r\n\n    request_get_mock.side_effect = side_effect\n\n    obj = Apprise.instantiate(\n        \"mmost://localhost/{bearer}?mode=bot&team={team}&to=#good,#bad\"\n        .format(bearer=bearer, team=team)\n    )\n    assert isinstance(obj, NotifyMattermost)\n    request_post_mock.return_value.status_code = requests.codes.created\n\n    assert obj.notify(body=\"body\", title=\"title\") is False\n    assert request_post_mock.call_count == 1\n    posted_json = json.loads(request_post_mock.call_args_list[0][1][\"data\"])\n    assert posted_json[\"channel_id\"] == \"cid-good\"\n"
  },
  {
    "path": "tests/test_plugin_messagebird.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.messagebird import NotifyMessageBird\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"msgbird://\",\n        {\n            # No hostname/apikey specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msgbird://{}/abcd\".format(\"a\" * 25),\n        {\n            # invalid characters in source phone number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msgbird://{}/123\".format(\"a\" * 25),\n        {\n            # invalid source phone number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msgbird://{}/15551232000\".format(\"a\" * 25),\n        {\n            # target phone number becomes who we text too; all is good\n            \"instance\": NotifyMessageBird,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"msgbird://a...a/15551232000\",\n        },\n    ),\n    (\n        \"msgbird://{}/15551232000/abcd\".format(\"a\" * 25),\n        {\n            # valid credentials\n            \"instance\": NotifyMessageBird,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"msgbird://{}/15551232000/123\".format(\"a\" * 25),\n        {\n            # valid credentials\n            \"instance\": NotifyMessageBird,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"msgbird://{}/?from=15551233000&to=15551232000\".format(\"a\" * 25),\n        {\n            # reference to to= and from=\n            \"instance\": NotifyMessageBird,\n        },\n    ),\n    (\n        \"msgbird://{}/15551232000\".format(\"a\" * 25),\n        {\n            \"instance\": NotifyMessageBird,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"msgbird://{}/15551232000\".format(\"a\" * 25),\n        {\n            \"instance\": NotifyMessageBird,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"msgbird://{}/15551232000\".format(\"a\" * 25),\n        {\n            \"instance\": NotifyMessageBird,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_messagebird_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_messagebird_edge_cases(mock_post):\n    \"\"\"NotifyMessageBird() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    # authkey = '{}'.format('a' * 24)\n    source = \"+1 (555) 123-3456\"\n\n    # No apikey specified\n    with pytest.raises(TypeError):\n        NotifyMessageBird(apikey=None, source=source)\n    with pytest.raises(TypeError):\n        NotifyMessageBird(apikey=\"     \", source=source)\n"
  },
  {
    "path": "tests/test_plugin_misskey.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.misskey import NotifyMisskey\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyMisskey\n    ##################################\n    (\n        \"misskey://\",\n        {\n            # Missing Everything :)\n            \"instance\": None,\n        },\n    ),\n    (\n        \"misskey://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"misskey://hostname\",\n        {\n            # Missing Access Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"misskey://access_token@hostname\",\n        {\n            # We're good; it's a simple notification\n            \"instance\": NotifyMisskey,\n        },\n    ),\n    (\n        \"misskeys://access_token@hostname\",\n        {\n            # We're good; it's another simple notification\n            \"instance\": NotifyMisskey,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"misskeys://a...n@hostname/\",\n        },\n    ),\n    (\n        \"misskey://hostname/?token=abcd123\",\n        {\n            # Our access token can be provided as a token= variable\n            \"instance\": NotifyMisskey,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"misskey://a...3@hostname\",\n        },\n    ),\n    (\n        \"misskeys://access_token@hostname:8443\",\n        {\n            # A custom port specified\n            \"instance\": NotifyMisskey,\n        },\n    ),\n    (\n        \"misskey://access_token@hostname?visibility=invalid\",\n        {\n            # An invalid visibility\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"misskeys://access_token@hostname?visibility=specified\",\n        {\n            # Specified a different visiblity\n            \"instance\": NotifyMisskey,\n        },\n    ),\n    (\n        \"misskeys://access_token@hostname\",\n        {\n            \"instance\": NotifyMisskey,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"misskeys://access_token@hostname\",\n        {\n            \"instance\": NotifyMisskey,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_misskey_urls():\n    \"\"\"NotifyMisskey() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_mqtt.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport re\nimport ssl\nimport sys\nfrom unittest.mock import ANY, Mock, call\n\nimport pytest\n\nimport apprise\nfrom apprise.plugins.mqtt import NotifyMQTT\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n\n@pytest.fixture\ndef mqtt_client_mock(mocker):\n    \"\"\"Mocks an MQTT client and response and returns the mocked client.\"\"\"\n\n    if \"paho\" not in sys.modules:\n        raise pytest.skip(\"Requires that `paho-mqtt` is installed\")\n\n    # Establish mock of the `publish()` response object.\n    publish_result = Mock(**{\n        \"rc\": 0,\n        \"is_published.return_value\": True,\n    })\n\n    # Establish mock of the `Client()` object.\n    mock_client = Mock(**{\n        \"connect.return_value\": 0,\n        \"reconnect.return_value\": 0,\n        \"is_connected.return_value\": True,\n        \"publish.return_value\": publish_result,\n    })\n    mocker.patch(\"paho.mqtt.client.Client\", return_value=mock_client)\n\n    return mock_client\n\n\n@pytest.mark.skipif(\n    \"paho\" in sys.modules, reason=\"Requires that `paho-mqtt` is NOT installed\"\n)\ndef test_plugin_mqtt_paho_import_error():\n    \"\"\"Verify `NotifyMQTT` is disabled when `paho.mqtt.client` fails\n    loading.\"\"\"\n\n    # without the library, the object can't be instantiated\n    obj = apprise.Apprise.instantiate(\"mqtt://user:pass@localhost/my/topic\")\n    assert obj is None\n\n\ndef test_plugin_mqtt_default_success(mqtt_client_mock):\n    \"\"\"Verify `NotifyMQTT` succeeds and has appropriate default settings.\"\"\"\n\n    # Instantiate the notifier.\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost:1234/my/topic\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMQTT)\n    # We only loaded 1 topic\n    assert len(obj) == 1\n    assert obj.url().startswith(\"mqtt://localhost:1234/my/topic\")\n\n    # Genrate the URL Identifier\n    assert isinstance(obj.url_id(), str)\n\n    # Verify default settings.\n    assert r\"qos=0\" in obj.url()\n    assert re.search(r\"version=v3.1.1\", obj.url())\n    assert r\"session=no\" in obj.url()\n    assert r\"client_id=\" not in obj.url()\n\n    # Verify notification succeeds.\n    assert obj.notify(body=\"test=test\") is True\n\n    # Send another notification (a new connection isn't attempted to be\n    # established as one already exists)\n    assert obj.notify(body=\"foo=bar\") is True\n\n    # Verify the right calls have been made to the MQTT client object.\n    assert mqtt_client_mock.mock_calls == [\n        call.max_inflight_messages_set(200),\n        call.connect(\"localhost\", port=1234, keepalive=30),\n        call.loop_start(),\n        call.is_connected(),\n        call.publish(\"my/topic\", payload=\"test=test\", qos=0, retain=False),\n        call.publish().is_published(),\n        call.is_connected(),\n        call.publish(\"my/topic\", payload=\"foo=bar\", qos=0, retain=False),\n        call.publish().is_published(),\n    ]\n\n\ndef test_plugin_mqtt_multiple_topics_success(mqtt_client_mock):\n    \"\"\"Verify submission to multiple MQTT topics.\"\"\"\n\n    # Designate multiple topic targets.\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic,my/other/topic\", suppress_exceptions=False\n    )\n\n    # Verify we have loaded 2 topics\n    assert len(obj) == 2\n\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.url().startswith(\"mqtt://localhost\")\n    assert r\"my/topic\" in obj.url()\n    assert r\"my/other/topic\" in obj.url()\n    assert obj.notify(body=\"test=test\") is True\n\n    # Verify the right calls have been made to the MQTT client object.\n    assert mqtt_client_mock.mock_calls == [\n        call.max_inflight_messages_set(200),\n        call.connect(\"localhost\", port=1883, keepalive=30),\n        call.loop_start(),\n        call.is_connected(),\n        call.publish(\"my/topic\", payload=\"test=test\", qos=0, retain=False),\n        call.publish().is_published(),\n        call.is_connected(),\n        call.publish(\n            \"my/other/topic\", payload=\"test=test\", qos=0, retain=False\n        ),\n        call.publish().is_published(),\n    ]\n\n\ndef test_plugin_mqtt_to_success(mqtt_client_mock):\n    \"\"\"Verify `NotifyMQTT` succeeds with the `to=` parameter.\"\"\"\n\n    # Leverage the `to=` argument to identify the topic.\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost?to=my/topic\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.url().startswith(\"mqtt://localhost/my/topic\")\n\n    # Verify default settings.\n    assert r\"qos=0\" in obj.url()\n    assert re.search(r\"version=v3.1.1\", obj.url())\n\n    # Verify notification succeeds.\n    assert obj.notify(body=\"test=test\") is True\n\n\ndef test_plugin_mqtt_valid_settings_success(mqtt_client_mock):\n    \"\"\"Verify settings as URL parameters will be accepted.\"\"\"\n\n    # Instantiate the notifier.\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic?qos=1&version=v3.1\",\n        suppress_exceptions=False,\n    )\n\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.url().startswith(\"mqtt://localhost\")\n    assert r\"qos=1\" in obj.url()\n    assert re.search(r\"version=v3.1\", obj.url())\n\n\ndef test_plugin_mqtt_invalid_settings_failure(mqtt_client_mock):\n    \"\"\"Verify notifier instantiation croaks on invalid settings.\"\"\"\n\n    # Test case for invalid/unsupported MQTT version.\n    with pytest.raises(TypeError):\n        apprise.Apprise.instantiate(\n            \"mqtt://localhost?version=v1.0.0.0\", suppress_exceptions=False\n        )\n\n    # Test case for invalid/unsupported `qos`.\n    with pytest.raises(TypeError):\n        apprise.Apprise.instantiate(\n            \"mqtt://localhost?qos=123\", suppress_exceptions=False\n        )\n\n    with pytest.raises(TypeError):\n        apprise.Apprise.instantiate(\n            \"mqtt://localhost?qos=invalid\", suppress_exceptions=False\n        )\n\n\ndef test_plugin_mqtt_bad_url_failure(mqtt_client_mock):\n    \"\"\"Verify notifier is disabled when using an invalid URL.\"\"\"\n    obj = apprise.Apprise.instantiate(\"mqtt://\", suppress_exceptions=False)\n    assert obj is None\n\n\ndef test_plugin_mqtt_no_topic_failure(mqtt_client_mock):\n    \"\"\"Verify notification fails when no topic is given.\"\"\"\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.notify(body=\"test=test\") is False\n\n\ndef test_plugin_mqtt_tls_connect_success(mqtt_client_mock):\n    \"\"\"Verify TLS encrypted connections work.\"\"\"\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtts://user:pass@localhost/my/topic\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.url().startswith(\"mqtts://user:pass@localhost/my/topic\")\n    assert obj.notify(body=\"test=test\") is True\n\n    # Verify the right calls have been made to the MQTT client object.\n    assert mqtt_client_mock.mock_calls == [\n        call.max_inflight_messages_set(200),\n        call.username_pw_set(\"user\", password=\"pass\"),\n        call.tls_set(\n            ca_certs=ANY,\n            certfile=None,\n            keyfile=None,\n            cert_reqs=ssl.VerifyMode.CERT_REQUIRED,\n            tls_version=ssl.PROTOCOL_TLS,\n            ciphers=None,\n        ),\n        call.tls_insecure_set(False),\n        call.connect(\"localhost\", port=8883, keepalive=30),\n        call.loop_start(),\n        call.is_connected(),\n        call.publish(\"my/topic\", payload=\"test=test\", qos=0, retain=False),\n        call.publish().is_published(),\n    ]\n\n\ndef test_plugin_mqtt_tls_no_certificates_failure(mqtt_client_mock, mocker):\n    \"\"\"Verify TLS does not work without access to CA root certificates.\"\"\"\n\n    # Clear CA certificates.\n    mocker.patch.object(NotifyMQTT, \"CA_CERTIFICATE_FILE_LOCATIONS\", [])\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtts://user:pass@localhost/my/topic\", suppress_exceptions=False\n    )\n    assert isinstance(obj, NotifyMQTT)\n\n    logger: Mock = mocker.spy(obj, \"logger\")\n\n    # Verify notification fails w/o CA certificates.\n    assert obj.notify(body=\"test=test\") is False\n\n    assert logger.mock_calls == [\n        call.error(\n            \"MQTT secure communication can not be verified, \"\n            \"CA certificates file missing\"\n        )\n    ]\n\n\ndef test_plugin_mqtt_tls_no_verify_success(mqtt_client_mock):\n    \"\"\"Verify TLS encrypted connections work with `verify=False`.\"\"\"\n\n    # A single user (not password) + no verifying of host\n    obj = apprise.Apprise.instantiate(\n        \"mqtts://user:pass@localhost/my/topic?verify=False\",\n        suppress_exceptions=False,\n    )\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.notify(body=\"test=test\") is True\n\n    # Verify the right calls have been made to the MQTT client object.\n    # Let's only validate the single call of interest is present.\n    # Everything else is identical with `test_plugin_mqtt_tls_connect_success`.\n    assert call.tls_insecure_set(True) in mqtt_client_mock.mock_calls\n\n\ndef test_plugin_mqtt_session_client_id_success(mqtt_client_mock):\n    \"\"\"Verify handling `session=yes` and `client_id=` works.\"\"\"\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://user@localhost/my/topic?session=yes&client_id=apprise\",\n        suppress_exceptions=False,\n    )\n\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.url().startswith(\"mqtt://user@localhost\")\n    assert r\"my/topic\" in obj.url()\n    assert r\"client_id=apprise\" in obj.url()\n    assert r\"session=yes\" in obj.url()\n    assert r\"retain=no\" in obj.url()\n    assert obj.notify(body=\"test=test\") is True\n\n\ndef test_plugin_mqtt_retain(mqtt_client_mock):\n    \"\"\"Verify handling of Retain Message Flag.\"\"\"\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://user@localhost/my/topic?retain=yes\", suppress_exceptions=False\n    )\n\n    assert isinstance(obj, NotifyMQTT)\n    assert obj.url().startswith(\"mqtt://user@localhost\")\n    assert r\"my/topic\" in obj.url()\n    assert r\"session=no\" in obj.url()\n    assert r\"retain=yes\" in obj.url()\n    assert obj.notify(body=\"test=test\") is True\n\n\ndef test_plugin_mqtt_connect_failure(mqtt_client_mock):\n    \"\"\"Verify `NotifyMQTT` fails when MQTT `connect()` fails.\"\"\"\n\n    # Emulate a situation where the `connect()` method fails.\n    mqtt_client_mock.connect.return_value = 2\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic\", suppress_exceptions=False\n    )\n\n    # Verify notification fails.\n    assert obj.notify(body=\"test=test\") is False\n\n\ndef test_plugin_mqtt_reconnect_failure(mqtt_client_mock):\n    \"\"\"Verify `NotifyMQTT` fails when MQTT `reconnect()` fails.\"\"\"\n\n    # Emulate a situation where MQTT reconnect fails.\n    mqtt_client_mock.reconnect.return_value = 2\n    mqtt_client_mock.is_connected.return_value = False\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic\", suppress_exceptions=False\n    )\n\n    # Verify notification fails.\n    assert obj.notify(body=\"test=test\") is False\n\n\ndef test_plugin_mqtt_publish_failure(mqtt_client_mock):\n    \"\"\"Verify `NotifyMQTT` fails when MQTT `publish()` fails.\"\"\"\n\n    # Emulate a situation where the `publish()` method fails.\n    mqtt_response = mqtt_client_mock.publish.return_value\n    mqtt_response.rc = 2\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic\", suppress_exceptions=False\n    )\n\n    # Verify notification fails.\n    assert obj.notify(body=\"test=test\") is False\n\n\ndef test_plugin_mqtt_exception_failure(mqtt_client_mock):\n    \"\"\"Verify `NotifyMQTT` fails when an exception happens.\"\"\"\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic\", suppress_exceptions=False\n    )\n\n    # Emulate a situation where `connect()` raises an exception.\n    mqtt_client_mock.connect.return_value = None\n\n    # Verify notification fails.\n    for side_effect in (ValueError, ConnectionError, ssl.CertificateError):\n        mqtt_client_mock.connect.side_effect = side_effect\n        assert obj.notify(body=\"test=test\") is False\n\n\ndef test_plugin_mqtt_not_published_failure(mqtt_client_mock, mocker):\n    \"\"\"Verify `NotifyMQTT` fails there if the message has not been\n    published.\"\"\"\n\n    # Speed up testing by making `NotifyMQTT` not block anywhere.\n    mocker.patch.object(NotifyMQTT, \"socket_read_timeout\", 0.00025)\n    mocker.patch.object(NotifyMQTT, \"mqtt_block_time_sec\", 0)\n\n    # Emulate a situation where `is_published()` returns `False`.\n    mqtt_response = mqtt_client_mock.publish.return_value\n    mqtt_response.is_published.return_value = False\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic\", suppress_exceptions=False\n    )\n\n    # Verify notification fails.\n    assert obj.notify(body=\"test=test\") is False\n\n\ndef test_plugin_mqtt_not_published_recovery_success(mqtt_client_mock):\n    \"\"\"Verify `NotifyMQTT` success after recovering from\n    is_published==False.\"\"\"\n\n    # Emulate a situation where `is_published()` returns `False`.\n    mqtt_response = mqtt_client_mock.publish.return_value\n    mqtt_response.is_published.return_value = None\n    mqtt_response.is_published.side_effect = (False, True)\n\n    obj = apprise.Apprise.instantiate(\n        \"mqtt://localhost/my/topic\", suppress_exceptions=False\n    )\n\n    # Verify notification fails.\n    assert obj.notify(body=\"test=test\") is True\n"
  },
  {
    "path": "tests/test_plugin_msg91.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.msg91 import NotifyMSG91\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"msg91://\",\n        {\n            # No hostname/authkey specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msg91://-\",\n        {\n            # Invalid AuthKey\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msg91://{}\".format(\"a\" * 23),\n        {\n            # valid AuthKey but no Template ID\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msg91://{}@{}\".format(\"t\" * 20, \"a\" * 23),\n        {\n            # Valid entry but no targets\n            \"instance\": NotifyMSG91,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"msg91://{}@{}/abcd\".format(\"t\" * 20, \"a\" * 23),\n        {\n            # No number to notify\n            \"instance\": NotifyMSG91,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"msg91://{}@{}/15551232000\".format(\"t\" * 20, \"a\" * 23),\n        {\n            # a valid message\n            \"instance\": NotifyMSG91,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"msg91://t...t@a...a/15551232000\",\n        },\n    ),\n    (\n        \"msg91://{}@{}/?to=15551232000&short_url=no\".format(\n            \"t\" * 20, \"a\" * 23\n        ),\n        {\n            # a valid message\n            \"instance\": NotifyMSG91,\n        },\n    ),\n    (\n        \"msg91://{}@{}/15551232000?short_url=yes\".format(\"t\" * 20, \"a\" * 23),\n        {\n            # testing short_url\n            \"instance\": NotifyMSG91,\n        },\n    ),\n    (\n        \"msg91://{}@{}/15551232000\".format(\"t\" * 20, \"a\" * 23),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyMSG91,\n        },\n    ),\n    (\n        \"msg91://{}@{}/15551232000\".format(\"t\" * 20, \"a\" * 23),\n        {\n            \"instance\": NotifyMSG91,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"msg91://{}@{}/15551232000\".format(\"t\" * 20, \"a\" * 23),\n        {\n            \"instance\": NotifyMSG91,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_msg91_urls():\n    \"\"\"NotifyMSG91() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_msg91_edge_cases(mock_post):\n    \"\"\"NotifyMSG91() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    target = \"+1 (555) 123-3456\"\n\n    # No authkey specified\n    with pytest.raises(TypeError):\n        NotifyMSG91(template=\"1234\", authkey=None, targets=target)\n    with pytest.raises(TypeError):\n        NotifyMSG91(template=\"1234\", authkey=\"    \", targets=target)\n    with pytest.raises(TypeError):\n        NotifyMSG91(template=\"     \", authkey=\"a\" * 23, targets=target)\n    with pytest.raises(TypeError):\n        NotifyMSG91(template=None, authkey=\"a\" * 23, targets=target)\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_msg91_keywords(mock_post):\n    \"\"\"NotifyMSG91() Templating.\"\"\"\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    target = \"+1 (555) 123-3456\"\n    template = \"12345\"\n    authkey = \"{}\".format(\"b\" * 32)\n\n    message_contents = \"test\"\n\n    # Variation of initialization without API key\n    obj = Apprise.instantiate(\n        f\"msg91://{template}@{authkey}/{target}?:key=value&:mobiles=ignored\"\n    )\n    assert isinstance(obj, NotifyMSG91)\n    assert isinstance(obj.url(), str)\n\n    # Send Notification\n    assert obj.send(body=message_contents) is True\n\n    # Validate expected call parameters\n    assert mock_post.call_count == 1\n    first_call = mock_post.call_args_list[0]\n\n    # URL and message parameters are the same for both calls\n    assert first_call[0][0] == \"https://control.msg91.com/api/v5/flow/\"\n    response = loads(first_call[1][\"data\"])\n    assert response[\"template_id\"] == template\n    assert response[\"short_url\"] == 0\n    assert len(response[\"recipients\"]) == 1\n    # mobiles is not over-ridden as it is a special reserved token\n    assert response[\"recipients\"][0][\"mobiles\"] == \"15551233456\"\n\n    # Our base tokens\n    assert response[\"recipients\"][0][\"body\"] == message_contents\n    assert response[\"recipients\"][0][\"type\"] == NotifyType.INFO.value\n    assert response[\"recipients\"][0][\"key\"] == \"value\"\n\n    mock_post.reset_mock()\n\n    # Play with mapping\n    obj = Apprise.instantiate(\n        f\"msg91://{template}@{authkey}/{target}?:body&:type=cat\"\n    )\n    assert isinstance(obj, NotifyMSG91)\n    assert isinstance(obj.url(), str)\n\n    # Send Notification\n    assert obj.send(body=message_contents) is True\n\n    # Validate expected call parameters\n    assert mock_post.call_count == 1\n    first_call = mock_post.call_args_list[0]\n    assert \"NotifyType.\" not in first_call[1][\"data\"]\n\n    # URL and message parameters are the same for both calls\n    assert first_call[0][0] == \"https://control.msg91.com/api/v5/flow/\"\n    response = loads(first_call[1][\"data\"])\n    assert response[\"template_id\"] == template\n    assert response[\"short_url\"] == 0\n    assert len(response[\"recipients\"]) == 1\n    assert response[\"recipients\"][0][\"mobiles\"] == \"15551233456\"\n    assert \"body\" not in response[\"recipients\"][0]\n    assert \"type\" not in response[\"recipients\"][0]\n    assert response[\"recipients\"][0][\"cat\"] == \"info\"\n"
  },
  {
    "path": "tests/test_plugin_msteams.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseConfig, NotifyType\nfrom apprise.plugins.msteams import NotifyMSTeams\n\nlogging.disable(logging.CRITICAL)\n\n# a test UUID we can use\nUUID4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyMSTeams\n    ##################################\n    (\n        \"msteams://\",\n        {\n            # First API Token not specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msteams://:@/\",\n        {\n            # We don't have strict host checking on for msteams, so this URL\n            # actually becomes parseable and :@ becomes a hostname.\n            # The below errors because a second token wasn't found\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"msteams://{UUID4}\",\n        {\n            # Just half of one token 1 provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"msteams://{UUID4}@{UUID4}/\",\n        {\n            # Just 1 tokens provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msteams://{}@{}/{}\".format(UUID4, UUID4, \"a\" * 32),\n        {\n            # Just 2 tokens provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msteams://{}@{}/{}/{}?t1\".format(UUID4, UUID4, \"b\" * 32, UUID4),\n        {\n            # All tokens provided - we're good\n            \"instance\": NotifyMSTeams,\n        },\n    ),\n    # Support native URLs\n    (\n        \"https://outlook.office.com/webhook/{}@{}/IncomingWebhook/{}/{}\"\n        .format(UUID4, UUID4, \"k\" * 32, UUID4),\n        {\n            # All tokens provided - we're good\n            \"instance\": NotifyMSTeams,\n            # Our expected url(privacy=True) startswith() response (v1 format)\n            \"privacy_url\": \"msteams://8...2/k...k/8...2/\",\n        },\n    ),\n    # Support New Native URLs\n    (\n        \"https://myteam.webhook.office.com/webhookb2/{}@{}/IncomingWebhook/{}/{}\"\n        .format(UUID4, UUID4, \"m\" * 32, UUID4),\n        {\n            # All tokens provided - we're good\n            \"instance\": NotifyMSTeams,\n            # Our expected url(privacy=True) startswith() response (v2 format):\n            \"privacy_url\": \"msteams://myteam/8...2/m...m/8...2/\",\n        },\n    ),\n    # Support Newer Native URLs with 4 tokens, introduced in 2025\n    (\n        \"https://myteam.webhook.office.com/webhookb2/{}@{}/IncomingWebhook/{}/{}\"\n        \"/{}\".format(UUID4, UUID4, \"m\" * 32, UUID4, \"V2-_\" + \"n\" * 43),\n        {\n            # All tokens provided - we're good\n            \"instance\": NotifyMSTeams,\n            # Our expected url(privacy=True) startswith() response (v2 format):\n            \"privacy_url\": \"msteams://myteam/8...2/m...m/8...2/V...n\",\n        },\n    ),\n    # Legacy URL Formatting\n    (\n        \"msteams://{}@{}/{}/{}?t2\".format(UUID4, UUID4, \"c\" * 32, UUID4),\n        {\n            # All tokens provided - we're good\n            \"instance\": NotifyMSTeams,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # Legacy URL Formatting\n    (\n        \"msteams://{}@{}/{}/{}?image=No\".format(UUID4, UUID4, \"d\" * 32, UUID4),\n        {\n            # All tokens provided - we're good  no image\n            \"instance\": NotifyMSTeams,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"msteams://8...2/d...d/8...2/\",\n        },\n    ),\n    # New 2021 URL formatting\n    (\n        \"msteams://apprise/{}@{}/{}/{}\".format(UUID4, UUID4, \"e\" * 32, UUID4),\n        {\n            # All tokens provided - we're good  no image\n            \"instance\": NotifyMSTeams,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"msteams://apprise/8...2/e...e/8...2/\",\n        },\n    ),\n    # New 2021 URL formatting; support team= argument\n    (\n        \"msteams://{}@{}/{}/{}?team=teamname\".format(\n            UUID4, UUID4, \"f\" * 32, UUID4\n        ),\n        {\n            # All tokens provided - we're good  no image\n            \"instance\": NotifyMSTeams,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"msteams://teamname/8...2/f...f/8...2/\",\n        },\n    ),\n    # New 2021 URL formatting (forcing v1)\n    (\n        \"msteams://apprise/{}@{}/{}/{}?version=1\".format(\n            UUID4, UUID4, \"e\" * 32, UUID4\n        ),\n        {\n            # All tokens provided - we're good\n            \"instance\": NotifyMSTeams,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"msteams://8...2/e...e/8...2/\",\n        },\n    ),\n    # Invalid versioning\n    (\n        \"msteams://apprise/{}@{}/{}/{}?version=999\".format(\n            UUID4, UUID4, \"e\" * 32, UUID4\n        ),\n        {\n            # invalid version\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msteams://apprise/{}@{}/{}/{}?version=invalid\".format(\n            UUID4, UUID4, \"e\" * 32, UUID4\n        ),\n        {\n            # invalid version\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"msteams://{}@{}/{}/{}?tx\".format(UUID4, UUID4, \"x\" * 32, UUID4),\n        {\n            \"instance\": NotifyMSTeams,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"msteams://{}@{}/{}/{}?ty\".format(UUID4, UUID4, \"y\" * 32, UUID4),\n        {\n            \"instance\": NotifyMSTeams,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"msteams://{}@{}/{}/{}?ta\".format(UUID4, UUID4, \"z\" * 32, UUID4),\n        {\n            \"instance\": NotifyMSTeams,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_msteams_urls():\n    \"\"\"NotifyMSTeams() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@pytest.fixture\ndef msteams_url():\n    return \"msteams://{}@{}/{}/{}\".format(UUID4, UUID4, \"a\" * 32, UUID4)\n\n\n@pytest.fixture\ndef request_mock(mocker):\n    \"\"\"Prepare requests mock.\"\"\"\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    return mock_post\n\n\n@pytest.fixture\ndef simple_template(tmpdir):\n    template = tmpdir.join(\"simple.json\")\n    template.write(\"\"\"\n    {\n      \"@type\": \"MessageCard\",\n      \"@context\": \"https://schema.org/extensions\",\n      \"summary\": \"{{name}}\",\n      \"themeColor\": \"{{app_color}}\",\n      \"sections\": [\n        {\n          \"activityImage\": null,\n          \"activityTitle\": \"{{title}}\",\n          \"text\": \"{{body}}\"\n        }\n      ]\n    }\n    \"\"\")\n    return template\n\n\ndef test_plugin_msteams_templating_basic_success(\n    request_mock, msteams_url, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() Templating - success.\n    Test cases where URL and JSON is valid.\n    \"\"\"\n\n    template = tmpdir.join(\"simple.json\")\n    template.write(\"\"\"\n    {\n      \"@type\": \"MessageCard\",\n      \"@context\": \"https://schema.org/extensions\",\n      \"summary\": \"{{app_id}}\",\n      \"themeColor\": \"{{app_color}}\",\n      \"sections\": [\n        {\n          \"activityImage\": null,\n          \"activityTitle\": \"{{app_title}}\",\n          \"text\": \"{{app_body}}\"\n        }\n      ]\n    }\n    \"\"\")\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=msteams_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token\",\n        )\n    )\n\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://outlook.office.com/webhook/\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Apprise\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"title\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"body\"\n\n\ndef test_plugin_msteams_templating_invalid_json(\n    request_mock, msteams_url, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() Templating - invalid JSON.\n    \"\"\"\n\n    template = tmpdir.join(\"invalid.json\")\n    template.write(\"}\")\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=msteams_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token\",\n        )\n    )\n\n    assert isinstance(obj, NotifyMSTeams)\n    # We will fail to preform our notifcation because the JSON is bad\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n\ndef test_plugin_msteams_templating_json_missing_type(\n    request_mock, msteams_url, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() Templating - invalid JSON.\n    Test case where we're missing the @type part of the URL.\n    \"\"\"\n\n    template = tmpdir.join(\"missing_type.json\")\n    template.write(\"\"\"\n    {\n      \"@context\": \"https://schema.org/extensions\",\n      \"summary\": \"{{app_id}}\",\n      \"themeColor\": \"{{app_color}}\",\n      \"sections\": [\n        {\n          \"activityImage\": null,\n          \"activityTitle\": \"{{app_title}}\",\n          \"text\": \"{{app_body}}\"\n        }\n      ]\n    }\n    \"\"\")\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=msteams_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token\",\n        )\n    )\n\n    assert isinstance(obj, NotifyMSTeams)\n\n    # We can not load the file because we're missing the @type entry\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n\ndef test_plugin_msteams_templating_json_missing_context(\n    request_mock, msteams_url, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() Templating - invalid JSON.\n    Test cases where we're missing the @context part of the URL.\n    \"\"\"\n\n    template = tmpdir.join(\"missing_context.json\")\n    template.write(\"\"\"\n    {\n      \"@type\": \"MessageCard\",\n      \"summary\": \"{{app_id}}\",\n      \"themeColor\": \"{{app_color}}\",\n      \"sections\": [\n        {\n          \"activityImage\": null,\n          \"activityTitle\": \"{{app_title}}\",\n          \"text\": \"{{app_body}}\"\n        }\n      ]\n    }\n    \"\"\")\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=msteams_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token\",\n        )\n    )\n    assert isinstance(obj, NotifyMSTeams)\n\n    # We can not load the file because we're missing the @context entry\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n\ndef test_plugin_msteams_templating_load_json_failure(\n    request_mock, msteams_url, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() Templating - template loading failure.\n    Test a case where we can not access the file.\n    \"\"\"\n\n    template = tmpdir.join(\"empty.json\")\n    template.write(\"\")\n\n    obj = Apprise.instantiate(f\"{msteams_url}/?template={template!s}\")\n\n    with mock.patch(\"json.loads\", side_effect=OSError):\n        # we fail, but this time it's because we couldn't\n        # access the cached file contents for reading\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is False\n        )\n\n\ndef test_plugin_msteams_templating_target_success(\n    request_mock, msteams_url, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() Templating - success with target.\n    A more complicated example; uses a target.\n    \"\"\"\n\n    template = tmpdir.join(\"more_complicated_example.json\")\n    template.write(\"\"\"\n    {\n      \"@type\": \"MessageCard\",\n      \"@context\": \"https://schema.org/extensions\",\n      \"summary\": \"{{app_desc}}\",\n      \"themeColor\": \"{{app_color}}\",\n      \"sections\": [\n        {\n          \"activityImage\": null,\n          \"activityTitle\": \"{{app_title}}\",\n          \"text\": \"{{app_body}}\"\n        }\n      ],\n     \"potentialAction\": [{\n        \"@type\": \"ActionCard\",\n        \"name\": \"Add a comment\",\n        \"inputs\": [{\n            \"@type\": \"TextInput\",\n            \"id\": \"comment\",\n            \"isMultiline\": false,\n            \"title\": \"Add a comment here for this task.\"\n        }],\n        \"actions\": [{\n            \"@type\": \"HttpPOST\",\n            \"name\": \"Add Comment\",\n            \"target\": \"{{ target }}\"\n        }]\n     }]\n    }\n    \"\"\")\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=msteams_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token&:target=http://localhost\",\n        )\n    )\n\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://outlook.office.com/webhook/\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Apprise Notifications\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"title\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"body\"\n\n    # We even parsed our entry out of the URL\n    assert (\n        posted_json[\"potentialAction\"][0][\"actions\"][0][\"target\"]\n        == \"http://localhost\"\n    )\n\n\ndef test_msteams_yaml_config_invalid_template_filename(\n    request_mock, msteams_url, simple_template, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() YAML Configuration Entries - invalid template filename.\n    \"\"\"\n\n    config = tmpdir.join(\"msteams01.yml\")\n    config.write(f\"\"\"\n    urls:\n      - {msteams_url}:\n        - tag: 'msteams'\n          template:  {simple_template!s}.missing\n          :name: 'Template.Missing'\n          :body: 'test body'\n          :title: 'test title'\n    \"\"\")\n\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n    assert len(cfg[0]) == 1\n\n    obj = cfg[0][0]\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    assert request_mock.called is False\n\n\ndef test_msteams_yaml_config_token_identifiers(\n    request_mock, msteams_url, simple_template, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() YAML Configuration Entries - test token identifiers.\n    \"\"\"\n\n    config = tmpdir.join(\"msteams01.yml\")\n    config.write(f\"\"\"\n    urls:\n      - {msteams_url}:\n        - tag: 'msteams'\n          template:  {simple_template!s}\n          :name: 'Testing'\n          :body: 'test body'\n          :title: 'test title'\n    \"\"\")\n\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n    assert len(cfg[0]) == 1\n\n    obj = cfg[0][0]\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://outlook.office.com/webhook/\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Testing\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"test title\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"test body\"\n\n\ndef test_msteams_yaml_config_no_bullet_under_url_1(\n    request_mock, msteams_url, simple_template, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() YAML Configuration Entries - no bullet 1.\n    Now again but without a bullet under the url definition.\n    \"\"\"\n\n    config = tmpdir.join(\"msteams02.yml\")\n    config.write(f\"\"\"\n    urls:\n      - {msteams_url}:\n          tag: 'msteams'\n          template:  {simple_template!s}\n          :name: 'Testing2'\n          :body: 'test body2'\n          :title: 'test title2'\n    \"\"\")\n\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n    assert len(cfg[0]) == 1\n\n    obj = cfg[0][0]\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://outlook.office.com/webhook/\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Testing2\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"test title2\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"test body2\"\n\n\ndef test_msteams_yaml_config_dictionary_file(\n    request_mock, msteams_url, simple_template, tmpdir\n):\n    \"\"\"NotifyMSTeams() YAML Configuration Entries.\n\n    Try again but store the content as a dictionary in the configuration file.\n    \"\"\"\n\n    config = tmpdir.join(\"msteams03.yml\")\n    config.write(f\"\"\"\n    urls:\n      - {msteams_url}:\n        - tag: 'msteams'\n          template:  {simple_template!s}\n          tokens:\n            name: 'Testing3'\n            body: 'test body3'\n            title: 'test title3'\n    \"\"\")\n\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n    assert len(cfg[0]) == 1\n\n    obj = cfg[0][0]\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://outlook.office.com/webhook/\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Testing3\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"test title3\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"test body3\"\n\n\ndef test_msteams_yaml_config_no_bullet_under_url_2(\n    request_mock, msteams_url, simple_template, tmpdir\n):\n    \"\"\"\n    NotifyMSTeams() YAML Configuration Entries - no bullet 2.\n    Now again but without a bullet under the url definition.\n    \"\"\"\n\n    config = tmpdir.join(\"msteams04.yml\")\n    config.write(f\"\"\"\n    urls:\n      - {msteams_url}:\n          tag: 'msteams'\n          template:  {simple_template!s}\n          tokens:\n            name: 'Testing4'\n            body: 'test body4'\n            title: 'test title4'\n    \"\"\")\n\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n    assert len(cfg[0]) == 1\n\n    obj = cfg[0][0]\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://outlook.office.com/webhook/\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Testing4\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"test title4\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"test body4\"\n\n\ndef test_msteams_yaml_config_combined(\n    request_mock, msteams_url, simple_template, tmpdir\n):\n    \"\"\"NotifyMSTeams() YAML Configuration Entries.\n\n    Now let's do a combination of the two.\n    \"\"\"\n\n    config = tmpdir.join(\"msteams05.yml\")\n    config.write(f\"\"\"\n    urls:\n      - {msteams_url}:\n        - tag: 'msteams'\n          template:  {simple_template!s}\n          tokens:\n              body: 'test body5'\n              title: 'test title5'\n          :name: 'Testing5'\n    \"\"\")\n\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n    assert len(cfg[0]) == 1\n\n    obj = cfg[0][0]\n    assert isinstance(obj, NotifyMSTeams)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://outlook.office.com/webhook/\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Testing5\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"test title5\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"test body5\"\n\n\ndef test_msteams_yaml_config_token_mismatch(\n    request_mock, msteams_url, simple_template, tmpdir\n):\n    \"\"\"NotifyMSTeams() YAML Configuration Entries.\n\n    Now let's do a test where our tokens is not the expected dictionary we want\n    to see.\n    \"\"\"\n\n    config = tmpdir.join(\"msteams06.yml\")\n    config.write(f\"\"\"\n    urls:\n      - {msteams_url}:\n        - tag: 'msteams'\n          template:  {simple_template!s}\n          # Not a dictionary\n          tokens:\n            body\n    \"\"\")\n\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n\n    # It could not load because of invalid tokens\n    assert len(cfg[0]) == 0\n\n\ndef test_plugin_msteams_edge_cases():\n    \"\"\"NotifyMSTeams() Edge Cases.\"\"\"\n    # Initializes the plugin with an invalid token\n    with pytest.raises(TypeError):\n        NotifyMSTeams(token_a=None, token_b=\"abcd\", token_c=\"abcd\")\n    # Whitespace also acts as an invalid token value\n    with pytest.raises(TypeError):\n        NotifyMSTeams(token_a=\"  \", token_b=\"abcd\", token_c=\"abcd\")\n\n    with pytest.raises(TypeError):\n        NotifyMSTeams(token_a=\"abcd\", token_b=None, token_c=\"abcd\")\n    # Whitespace also acts as an invalid token value\n    with pytest.raises(TypeError):\n        NotifyMSTeams(token_a=\"abcd\", token_b=\"  \", token_c=\"abcd\")\n\n    with pytest.raises(TypeError):\n        NotifyMSTeams(token_a=\"abcd\", token_b=\"abcd\", token_c=None)\n    # Whitespace also acts as an invalid token value\n    with pytest.raises(TypeError):\n        NotifyMSTeams(token_a=\"abcd\", token_b=\"abcd\", token_c=\"  \")\n\n    uuid4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n    token_a = f\"{uuid4}@{uuid4}\"\n    token_b = \"A\" * 32\n    # test case where no tokens are specified\n    obj = NotifyMSTeams(token_a=token_a, token_b=token_b, token_c=uuid4)\n    assert isinstance(obj, NotifyMSTeams)\n"
  },
  {
    "path": "tests/test_plugin_nextcloud.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nfrom json import dumps\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAsset, NotifyType, PersistentStoreMode\nfrom apprise.plugins.nextcloud import NotifyNextcloud\n\nNEXTCLOUD_GOOD_RESPONSE = dumps({\n    \"ocs\": {\n        \"meta\": {\"status\": \"ok\", \"statuscode\": 100},\n        \"data\": {\"users\": [\"user1\", \"user2\"]},\n    }})\n\nlogging.disable(logging.CRITICAL)\n\napprise_url_tests = (\n    ##################################\n    # NotifyNextcloud\n    ##################################\n    (\n        \"ncloud://:@/\",\n        {\n            \"instance\": None,\n            # Our response expected server response\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://\",\n        {\n            \"instance\": None,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"nclouds://\",\n        {\n            # No hostname\n            \"instance\": None,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://localhost\",\n        {\n            # No user specified\n            \"instance\": NotifyNextcloud,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1,user2&version=invalid\",\n        {\n            # An invalid version was specified\n            \"instance\": TypeError,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1,user2&version=0\",\n        {\n            # An invalid version was specified\n            \"instance\": TypeError,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1,user2&version=-23\",\n        {\n            # An invalid version was specified\n            \"instance\": TypeError,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://localhost/admin\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost/admin\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1,user2\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1,user2&version=20\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1,user2&version=21\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1&version=20&url_prefix=/abcd\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user@localhost?to=user1&version=21&url_prefix=/abcd\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user:pass@localhost/user1/user2\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ncloud://user:****@localhost/@user1/@user2\",\n        },\n    ),\n    (\n        \"ncloud://user:pass@localhost/#group1/#group2/#group1\",\n        {\n            # Test groups, but also note a duplicate group provided\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n            \"privacy_url\": \"ncloud://user:****@localhost/#group\",\n        },\n    ),\n    (\n        \"ncloud://user:pass@localhost:8080/admin\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"nclouds://user:pass@localhost/admin\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"nclouds://user:****@localhost/@admin\",\n        },\n    ),\n    (\n        \"nclouds://user:pass@localhost:8080/admin/\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"nclouds://user:pass@localhost:8080/#group/\",\n        {\n            \"instance\": NotifyNextcloud,\n            # Invalid JSON Response\n            \"requests_response_text\": \"{\",\n            # We will fail to make the notify() call due to our bad response\n            \"notify_response\": False,\n        },\n    ),\n\n    (\n        \"ncloud://localhost:8080/admin?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"ncloud://user:pass@localhost:8081/admin\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"ncloud://user:pass@localhost:8082/admin\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"ncloud://user:pass@localhost:8083/user1/user2/user3\",\n        {\n            \"instance\": NotifyNextcloud,\n            \"requests_response_text\": NEXTCLOUD_GOOD_RESPONSE,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_nextcloud_urls():\n    \"\"\"NotifyNextcloud() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_nextcloud_edge_cases(mock_post):\n    \"\"\"NotifyNextcloud() Edge Cases.\"\"\"\n\n    # A response\n    robj = mock.Mock()\n    robj.content = \"\"\n    robj.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = robj\n\n    # Variation Initializations\n    obj = NotifyNextcloud(\n        host=\"localhost\", user=\"admin\", password=\"pass\", targets=\"user\"\n    )\n    assert isinstance(obj, NotifyNextcloud) is True\n    assert isinstance(obj.url(), str) is True\n\n    # An empty body\n    assert obj.send(body=\"\") is True\n    assert \"data\" in mock_post.call_args_list[0][1]\n    assert \"shortMessage\" in mock_post.call_args_list[0][1][\"data\"]\n    # The longMessage argument is not set\n    assert \"longMessage\" not in mock_post.call_args_list[0][1][\"data\"]\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_nextcloud_url_prefix(mock_post):\n    \"\"\"NotifyNextcloud() URL Prefix Testing.\"\"\"\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n\n    # Prepare our mock object\n    mock_post.return_value = response\n\n    # instantiate our object (without a batch mode)\n    obj = Apprise.instantiate(\n        \"ncloud://localhost/admin/?version=20&url_prefix=/abcd\"\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Not set to batch, so we send 2 different messages\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"http://localhost/abcd/ocs/v2.php/apps/\"\n        \"admin_notifications/api/v1/notifications/admin\")\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_nextcloud_groups_and_all(mock_get, mock_post):\n    \"\"\"NotifyNextcloud() Group and All user expansion.\"\"\"\n\n    # Mock POST success\n    post_resp = mock.Mock()\n    post_resp.content = \"\"\n    post_resp.status_code = requests.codes.ok\n    mock_post.return_value = post_resp\n\n    # Mock GET responses for group and users listing\n    def get_side_effect(url, *args, **kwargs):\n        resp = mock.Mock()\n        if \"/ocs/v1.php/cloud/groups/\" in url:\n            # Return JSON for group\n            j = {\n                \"ocs\": {\n                    \"meta\": {\"status\": \"ok\", \"statuscode\": 100},\n                    \"data\": {\"users\": [\"user1\", \"user2\"]},\n                }\n            }\n            resp.status_code = requests.codes.ok\n            resp.json = lambda: j\n            resp.content = dumps(j).encode()\n            return resp\n\n        elif \"/ocs/v1.php/cloud/users\" in url:\n            j = {\n                \"ocs\": {\n                    \"meta\": {\"status\": \"ok\", \"statuscode\": 100},\n                    \"data\": {\"users\": [\"user1\", \"user3\"]},\n                }\n            }\n            resp.status_code = requests.codes.ok\n            resp.json = lambda: j\n            resp.content = dumps(j).encode()\n            return resp\n        # default\n        resp.status_code = requests.codes.ok\n        resp.content = b\"\"\n        resp.json = lambda: {}\n        return resp\n\n    mock_get.side_effect = get_side_effect\n\n    # Instantiate with a mix of targets: group, all, and direct user\n    obj = NotifyNextcloud(\n        host=\"localhost\",\n        user=\"admin\",\n        password=\"pass\",\n        targets=[\"#devs\", \"all\", \"user4\"],\n    )\n\n    assert isinstance(obj, NotifyNextcloud)\n\n    # Send notification\n    assert (\n        obj.send(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Expected resolved users (deduplicated): user1, user2, user3, user4\n    # Hence 4 POST calls\n    assert mock_post.call_count == 4\n\n    # Validate calls were made to expected endpoints\n    called_urls = [c[0][0] for c in mock_post.call_args_list]\n    for u in (\"user1\", \"user2\", \"user3\", \"user4\"):\n        assert any(u in url for url in called_urls)\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_nextcloud_persistent_storage(mock_get, mock_post, tmpdir):\n    \"\"\"Testing persistent storage\"\"\"\n\n    post_resp = mock.Mock()\n    post_resp.content = \"\"\n    post_resp.status_code = requests.codes.ok\n    mock_post.return_value = post_resp\n\n    # Set up persistent storage\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir),\n    )\n\n    def get_side_effect(url, *args, **kwargs):\n        resp = mock.Mock()\n        # Default json() to empty\n        resp.json = lambda: {}\n\n        if \"/ocs/v1.php/cloud/groups\" in url:\n            resp.status_code = requests.codes.ok\n            payload = {\"ocs\": {\"data\": {\"users\": [\"u1\"]}}}\n            resp.json = lambda: payload\n            resp.content = dumps(payload).encode()\n            return resp\n\n        elif \"/ocs/v1.php/cloud/users\" in url:\n            resp.status_code = requests.codes.ok\n            payload = {\"ocs\": {\"data\": {\"users\": [\"u2\"]}}}\n            resp.json = lambda: payload\n            resp.content = dumps(payload).encode()\n            return resp\n\n        resp.status_code = 500\n        resp.content = b\"\"\n        return resp\n\n    mock_get.side_effect = get_side_effect\n\n    obj = NotifyNextcloud(\n        host=\"localhost\",\n        user=\"admin\",\n        password=\"pass\",\n        targets=[\"#devs\", \"all\"],\n        asset=asset,\n    )\n    # We failed to get our list\n    assert obj.send(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n\n    # User and Group looked up\n    assert mock_get.call_count == 2\n\n    # Expect users u1 (group) and u2 (all)\n    assert mock_post.call_count == 2\n    called_urls = [c[0][0] for c in mock_post.call_args_list]\n    for u in (\"u1\", \"u2\"):\n        assert any(u in url for url in called_urls)\n\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    obj = NotifyNextcloud(\n        host=\"localhost\",\n        user=\"admin\",\n        password=\"pass\",\n        targets=[\"#devs\"],\n        asset=asset,\n    )\n    # We succeeded this time\n    assert obj.send(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n\n    # Expect users u1 (group) only and pulled from cache\n    assert mock_get.call_count == 0\n    assert mock_post.call_count == 1\n    assert mock_post.call_args_list[0][0][0].endswith(\"/u1\")\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_nextcloud_groups_errors_and_dedup(mock_get, mock_post):\n    \"\"\"Non-200/exception paths return empty lists and dedup still applies.\"\"\"\n\n    post_resp = mock.Mock()\n    post_resp.content = \"\"\n    post_resp.status_code = requests.codes.ok\n    mock_post.return_value = post_resp\n\n    # Non-200 for group and users; JSON invalid\n    def get_side_effect(url, *args, **kwargs):\n        resp = mock.Mock()\n        resp.status_code = 401\n        resp.content = b\"<ocs><data></data></ocs>\"\n        # Return empty/invalid JSON to drive empty path\n        resp.json = lambda: {}\n        return resp\n\n    mock_get.side_effect = get_side_effect\n\n    # Provide duplicates alongside failing expansions\n    obj = NotifyNextcloud(\n        host=\"localhost\",\n        user=\"admin\",\n        password=\"pass\",\n        targets=[\"#devs\", \"all\", \"user1\", \"user1\", \"user2\"],\n    )\n\n    # We failed to hit the server for data\n    assert obj.send(body=\"x\", title=\"y\", notify_type=NotifyType.INFO) is False\n\n    # we have no control over the order, but we know that on the first\n    # GET call, we'd have gotten a 401 response; so we'd have stopped from\n    # that point further\n    assert mock_get.call_count == 1\n\n    # Nothing notified\n    assert mock_post.call_count == 0\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_nextcloud_req_exception_and_empty_targets(mock_get, mock_post):\n    \"\"\"RequestException returns empty expansion; direct users send.\"\"\"\n\n    post_resp = mock.Mock()\n    post_resp.content = \"\"\n    post_resp.status_code = requests.codes.ok\n    mock_post.return_value = post_resp\n\n    def get_side_effect(url, *args, **kwargs):\n        raise requests.RequestException(\"boom\")\n\n    mock_get.side_effect = get_side_effect\n\n    obj = NotifyNextcloud(\n        host=\"localhost\",\n        user=\"admin\",\n        password=\"pass\",\n        targets=[\"\", \"   \", \"#DevTeam\", \"#\", \"userX\"],\n    )\n\n    # Our Group inquiry failed to respond\n    assert obj.send(body=\"x\", title=\"y\", notify_type=NotifyType.INFO) is False\n    assert mock_post.call_count == 0\n    assert mock_get.call_count == 1\n    assert mock_get.call_args_list[0][0][0] \\\n        == \"http://localhost/ocs/v1.php/cloud/groups/DevTeam\"\n    assert mock_get.call_args_list[0][1][\"params\"].get(\"format\") == \"json\"\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_nextcloud_json_empty_returns_empty(mock_get, mock_post):\n    \"\"\"Invalid/empty JSON returns empty; direct users still send.\"\"\"\n\n    post_resp = mock.Mock()\n    post_resp.content = \"\"\n    post_resp.status_code = requests.codes.ok\n    mock_post.return_value = post_resp\n\n    def get_side_effect(url, *args, **kwargs):\n        resp = mock.Mock()\n        resp.json = lambda: {}\n        resp.status_code = requests.codes.ok\n        resp.content = b\"{}\"\n        return resp\n\n    mock_get.side_effect = get_side_effect\n\n    obj = NotifyNextcloud(\n        host=\"localhost\",\n        user=\"admin\",\n        password=\"pass\",\n        targets=[\"#broken\", \"all\", \"userZ\"],\n    )\n\n    # Our notification\n    assert obj.send(body=\"x\", title=\"y\", notify_type=NotifyType.INFO) is True\n    # Only direct userZ posts because both expansions return empty\n    assert mock_get.call_count == 2\n    assert any(\"/cloud/users\" in call[0][0]\n               for call in mock_get.call_args_list)\n    assert any(\"/cloud/groups/broken\" in call[0][0]\n               for call in mock_get.call_args_list)\n\n    # userZ would get a notification\n    assert mock_post.call_count == 1\n    assert mock_post.call_args_list[0][0][0].endswith(\"/userZ\")\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_nextcloud_caching_group_and_all(mock_get, mock_post):\n    \"\"\"Cache hits avoid repeat OCS lookups.\"\"\"\n\n    # First round of GETs return users for group and all\n    def get_side_effect(url, *args, **kwargs):\n        resp = mock.Mock()\n        resp.status_code = requests.codes.ok\n        if \"/ocs/v1.php/cloud/groups\" in url:\n            j = {\n                \"ocs\": {\n                    \"meta\": {\"status\": \"ok\", \"statuscode\": 100},\n                    \"data\": {\"users\": [\"g1\", \"g2\"]},\n                }\n            }\n        elif \"/ocs/v1.php/cloud/users\" in url:\n            j = {\n                \"ocs\": {\n                    \"meta\": {\"status\": \"ok\", \"statuscode\": 100},\n                    \"data\": {\"users\": [\"a1\", \"a2\"]},\n                }\n            }\n        else:\n            j = {\"ocs\": {\"data\": {\"users\": []}}}\n        resp.json = lambda: j\n        resp.content = dumps(j).encode()\n        return resp\n\n    mock_get.side_effect = get_side_effect\n\n    post_resp = mock.Mock()\n    post_resp.content = \"\"\n    post_resp.status_code = requests.codes.ok\n    mock_post.return_value = post_resp\n\n    obj = NotifyNextcloud(\n        host=\"localhost\",\n        user=\"admin\",\n        password=\"pass\",\n        targets=[\"#devs\", \"all\", \"@joe\"],\n    )\n\n    # First send: resolves via OCS; expect 2 GETs (group + all)\n    assert obj.send(body=\"b\", title=\"t\", notify_type=NotifyType.INFO) is True\n    assert mock_get.call_count == 2\n    called = \"\".join(c[0][0] for c in mock_get.call_args_list)\n    assert \"/cloud/groups/\" in called and \"/cloud/users\" in called\n\n    # we sent 5 notifications\n    assert mock_post.call_count == 5\n    expected_users = {\"a1\", \"a2\", \"g1\", \"g2\", \"joe\"}\n\n    # Extract the user segment from the URL of each call\n    actual_users = {\n        call[0][0].split(\"/\")[-1]\n        for call in mock_post.call_args_list\n    }\n\n    # Assert that the set of actual users matches the set of expected users\n    assert actual_users == expected_users\n\n    # Reset our mock object\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    assert obj.send(body=\"b2\", title=\"t2\", notify_type=NotifyType.INFO) is True\n    # Cached responses were used to get our user information\n    assert mock_get.call_count == 0\n    assert mock_post.call_count == 5\n\n    # We can re-verify our notifications went as expected:\n    actual_users = {\n        call[0][0].split(\"/\")[-1]\n        for call in mock_post.call_args_list\n    }\n\n    # Assert that the set of actual users matches the set of expected users\n    assert actual_users == expected_users\n"
  },
  {
    "path": "tests/test_plugin_nextcloudtalk.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.nextcloudtalk import NotifyNextcloudTalk\n\nlogging.disable(logging.CRITICAL)\n\napprise_url_tests = (\n    ##################################\n    # NotifyNextcloudTalk\n    ##################################\n    (\n        \"nctalk://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"nctalk://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"nctalks://\",\n        {\n            # No hostname\n            \"instance\": None,\n        },\n    ),\n    (\n        \"nctalk://localhost\",\n        {\n            # No user and password and roomid specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nctalk://localhost/roomid\",\n        {\n            # No user and password specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nctalk://user@localhost/roomid\",\n        {\n            # No password specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost\",\n        {\n            # No roomid specified\n            \"instance\": NotifyNextcloudTalk,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost/roomid1/roomid2\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            \"requests_response_code\": requests.codes.created,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"nctalk://user:****@localhost/roomid1/roomid2\",\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost:8080/roomid\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost:8080/roomid?url_prefix=/prefix\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"nctalks://user:pass@localhost/roomid\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            \"requests_response_code\": requests.codes.created,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"nctalks://user:****@localhost/roomid\",\n        },\n    ),\n    (\n        \"nctalks://user:pass@localhost:8080/roomid/\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost:8080/roomid?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            \"requests_response_code\": requests.codes.created,\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost:8081/roomid\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost:8082/roomid\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"nctalk://user:pass@localhost:8083/roomid1/roomid2/roomid3\",\n        {\n            \"instance\": NotifyNextcloudTalk,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_nextcloudtalk_urls():\n    \"\"\"NotifyNextcloudTalk() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_nextcloudtalk_edge_cases(mock_post):\n    \"\"\"NotifyNextcloudTalk() Edge Cases.\"\"\"\n\n    # A response\n    robj = mock.Mock()\n    robj.content = \"\"\n    robj.status_code = requests.codes.created\n\n    # Prepare Mock\n    mock_post.return_value = robj\n\n    # Variation Initializations\n    obj = NotifyNextcloudTalk(\n        host=\"localhost\", user=\"admin\", password=\"pass\", targets=\"roomid\"\n    )\n    assert isinstance(obj, NotifyNextcloudTalk) is True\n    assert isinstance(obj.url(), str) is True\n\n    # An empty body\n    assert obj.send(body=\"\") is True\n    assert \"data\" in mock_post.call_args_list[0][1]\n    assert \"message\" in mock_post.call_args_list[0][1][\"data\"]\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_nextcloud_talk_url_prefix(mock_post):\n    \"\"\"NotifyNextcloudTalk() URL Prefix Testing.\"\"\"\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.created\n\n    # Prepare our mock object\n    mock_post.return_value = response\n\n    # instantiate our object (without a batch mode)\n    obj = Apprise.instantiate(\n        \"nctalk://user:pass@localhost/admin/?url_prefix=/abcd\"\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Not set to batch, so we send 2 different messages\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"http://localhost/abcd/ocs/v2.php/apps/spreed/api/v1/chat/admin\"\n    )\n\n    mock_post.reset_mock()\n\n    # instantiate our object (without a batch mode)\n    obj = Apprise.instantiate(\n        \"nctalk://user:pass@localhost/admin/?url_prefix=a/longer/path/abcd/\"\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Not set to batch, so we send 2 different messages\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"http://localhost/a/longer/path/abcd/\"\n        \"ocs/v2.php/apps/spreed/api/v1/chat/admin\"\n    )\n"
  },
  {
    "path": "tests/test_plugin_notica.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.notica import NotifyNotica\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"notica://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notica://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Native URL\n    (\n        \"https://notica.us/?%s\" % (\"z\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notica://z...z/\",\n        },\n    ),\n    # Native URL with additional arguments\n    (\n        \"https://notica.us/?%s&overflow=upstream\" % (\"z\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notica://z...z/\",\n        },\n    ),\n    # Token specified\n    (\n        \"notica://%s\" % (\"a\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notica://a...a/\",\n        },\n    ),\n    # Self-Hosted configuration\n    (\n        \"notica://localhost/%s\" % (\"b\" * 6),\n        {\n            \"instance\": NotifyNotica,\n        },\n    ),\n    (\n        \"notica://user@localhost/%s\" % (\"c\" * 6),\n        {\n            \"instance\": NotifyNotica,\n        },\n    ),\n    (\n        \"notica://user:pass@localhost/%s/\" % (\"d\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notica://user:****@localhost/d...d\",\n        },\n    ),\n    (\n        \"notica://user:pass@localhost/a/path/%s/\" % (\"r\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notica://user:****@localhost/a/path/r...r\",\n        },\n    ),\n    (\n        \"notica://localhost:8080/%s\" % (\"a\" * 6),\n        {\n            \"instance\": NotifyNotica,\n        },\n    ),\n    (\n        \"notica://user:pass@localhost:8080/%s\" % (\"b\" * 6),\n        {\n            \"instance\": NotifyNotica,\n        },\n    ),\n    (\n        \"noticas://localhost/%s\" % (\"j\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            \"privacy_url\": \"noticas://localhost/j...j\",\n        },\n    ),\n    (\n        \"noticas://user:pass@localhost/%s\" % (\"e\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"noticas://user:****@localhost/e...e\",\n        },\n    ),\n    (\n        \"noticas://localhost:8080/path/%s\" % (\"5\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            \"privacy_url\": \"noticas://localhost:8080/path/5...5\",\n        },\n    ),\n    (\n        \"noticas://user:pass@localhost:8080/%s\" % (\"6\" * 6),\n        {\n            \"instance\": NotifyNotica,\n        },\n    ),\n    (\n        \"notica://%s\" % (\"b\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # Test Header overrides\n    (\n        \"notica://localhost:8080//%s/?+HeaderKey=HeaderValue\" % (\"7\" * 6),\n        {\n            \"instance\": NotifyNotica,\n        },\n    ),\n    (\n        \"notica://%s\" % (\"c\" * 6),\n        {\n            \"instance\": NotifyNotica,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"notica://%s\" % (\"d\" * 7),\n        {\n            \"instance\": NotifyNotica,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"notica://%s\" % (\"e\" * 8),\n        {\n            \"instance\": NotifyNotica,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_notica_urls():\n    \"\"\"NotifyNotica() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_notifiarr.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import NotifyType\nfrom apprise.plugins.notifiarr import NotifyNotifiarr\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"notifiarr://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notifiarr://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notifiarr://apikey\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Response will fail due to no targets defined\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://a...y\",\n        },\n    ),\n    (\n        \"notifiarr://apikey/1234/?event=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notifiarr://apikey/%%invalid%%\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Response will fail due to no targets defined\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://a...y\",\n        },\n    ),\n    (\n        \"notifiarr://apikey/#123\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://a...y/#123\",\n        },\n    ),\n    (\n        \"notifiarr://apikey/123?image=No\",\n        {\n            \"instance\": NotifyNotifiarr,\n        },\n    ),\n    (\n        \"notifiarr://apikey/123?image=yes\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://a...y/#123\",\n        },\n    ),\n    (\n        \"notifiarr://apikey/?to=123,432\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://a...y/#123/#432\",\n        },\n    ),\n    (\n        \"notifiarr://apikey/?to=123,432&event=1234\",\n        {\n            # Test event\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://a...y/#123/#432\",\n        },\n    ),\n    (\n        \"notifiarr://123/?apikey=myapikey\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://m...y/#123\",\n        },\n    ),\n    (\n        \"notifiarr://123/?key=myapikey\",\n        {\n            # Support key=\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://m...y/#123\",\n        },\n    ),\n    (\n        \"notifiarr://123/?apikey=myapikey&image=yes\",\n        {\n            \"instance\": NotifyNotifiarr,\n        },\n    ),\n    (\n        \"notifiarr://123/?apikey=myapikey&image=no\",\n        {\n            \"instance\": NotifyNotifiarr,\n        },\n    ),\n    (\n        \"notifiarr://123/?apikey=myapikey&source=My%20System\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://m...y/#123\",\n        },\n    ),\n    (\n        \"notifiarr://123/?apikey=myapikey&from=My%20System\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://m...y/#123\",\n        },\n    ),\n    (\n        \"notifiarr://?apikey=myapikey\",\n        {\n            # No Channel or host\n            \"instance\": NotifyNotifiarr,\n            # Response will fail due to no targets defined\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://m...y/\",\n        },\n    ),\n    (\n        \"notifiarr://invalid?apikey=myapikey\",\n        {\n            # No Channel or host\n            \"instance\": NotifyNotifiarr,\n            # invalid channel\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://m...y/\",\n        },\n    ),\n    (\n        \"notifiarr://123/325/?apikey=myapikey\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifiarr://m...y/#123/#325\",\n        },\n    ),\n    (\n        \"notifiarr://apikey/123/\",\n        {\n            \"instance\": NotifyNotifiarr,\n        },\n    ),\n    (\n        \"notifiarr://apikey/123\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"notifiarr://apikey/123\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"notifiarr://apikey/123\",\n        {\n            \"instance\": NotifyNotifiarr,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_notifiarr_urls():\n    \"\"\"NotifyNotifiarr() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_notifiarr_notifications(mock_post):\n    \"\"\"NotifyNotifiarr() Notifications/Ping Support.\"\"\"\n\n    # Test our header parsing when not lead with a header\n    body = cleandoc(\"\"\"\n    # Heading\n    @everyone and @admin, wake and meet our new user <@123> and <@987>;\n    Attention Roles: <@&456> and <@&765>\n     \"\"\")\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock return object\n    mock_post.return_value = response\n\n    results = NotifyNotifiarr.parse_url(\"notifiarr://apikey/12345\")\n\n    instance = NotifyNotifiarr(**results)\n    assert isinstance(instance, NotifyNotifiarr)\n\n    response = instance.send(body=body)\n    assert response is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert \"NotifyType.\" not in details[1][\"data\"]\n    assert details[0][0] == \"https://notifiarr.com/api/v1/notification/apprise\"\n\n    payload = loads(details[1][\"data\"])\n\n    # First role and first user stored\n    assert payload == {\n        \"source\": \"Apprise\",\n        \"type\": NotifyType.INFO.value,\n        \"notification\": {\"update\": False, \"name\": \"Apprise\", \"event\": \"\"},\n        \"discord\": {\n            \"color\": \"#3AA3E3\",\n            \"ping\": {\n                # Only supports 1 entry each; so first one is parsed\n                \"pingUser\": \"123\",\n                \"pingRole\": \"456\",\n            },\n            \"text\": {\n                \"title\": \"\",\n                \"content\": \"👉 @everyone @admin <@123> <@987> <@&456> <@&765>\",\n                \"description\": (\n                    \"# Heading\\n@everyone and @admin, wake and meet our new \"\n                    \"user <@123> and <@987>;\\nAttention Roles: <@&456> and \"\n                    \"<@&765>\\n \"\n                ),\n                \"footer\": \"Apprise Notifications\",\n            },\n            \"ids\": {\"channel\": 12345},\n        },\n    }\n"
  },
  {
    "path": "tests/test_plugin_notificationapi.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.notificationapi import NotifyNotificationAPI\n\nlogging.disable(logging.CRITICAL)\n\nNOTIFICATIONAPI_GOOD_RESPONSE = dumps({})\n\nNOTIFICATIONAPI_BAD_RESPONSE = \"{\"\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\"napi://\", {\n        \"instance\": TypeError,\n    }),\n    (\"napi://:@/\", {\n        \"instance\": TypeError,\n    }),\n    (\"napi://abcd\", {\n        # invalid from email\n        \"instance\": TypeError,\n    }),\n    (\"napi://abcd@host.com\", {\n        # Just an Email specified, no client_id or client_secret\n        \"instance\": TypeError,\n    }),\n    (\"napi://user@client_id/cs14a/user@example.ca\", {\n        # No id matched\n        \"instance\": TypeError,\n    }),\n    (\"napi://user@client_id/cs14b/+15551235553\", {\n        # No id matched\n        \"instance\": TypeError,\n    }),\n    (\"napi://user@client_id/cs14c/+15551235553/user@example.ca\", {\n        # No id matched\n        \"instance\": TypeError,\n    }),\n\n    (\"napi://type@client_id/client_secret/id/+15551235553/?mode=invalid\", {\n        # Invalid mode\n        \"instance\": TypeError,\n    }),\n    (\"napi://type@client_id/client_secret/id/+15551235553/?region=invalid\", {\n        # Invalid region\n        \"instance\": TypeError,\n    }),\n    ((\n        \"napi://type@client_id/client_secret/id/user@example.ca/\"\n        \"user2@example.ca\"\n        ), {\n        # to many emails assigned to id (variation 1)\n        \"instance\": TypeError,\n    }),\n    ((\n        \"napi://type@client_id/client_secret/user@example.ca/\"\n        \"user2@example.ca\"\n        ), {\n        # to many emails assigned to id (variation 2)\n        \"instance\": TypeError,\n    }),\n    ((\n        \"napi://type@client_id/client_secret/id/+15551235553/\"\n        \"+15551235555\"\n        ), {\n        # to many phone no's assigned to id (variation 1)\n        \"instance\": TypeError,\n    }),\n    ((\n        \"napi://type@client_id/client_secret/+15551235553/\"\n        \"+15551235555\"\n        ), {\n        # to many phone no's assigned to id (variation 2)\n        \"instance\": TypeError,\n    }),\n    (\"napi://type@client_id/client_secret/id/+15551235553/?mode=invalid\", {\n        # Invalid mode\n        \"instance\": TypeError,\n    }),\n    (\"napi://client_id/client_secret/id/+15551231234/?type=*(\", {\n        # Invalid type\n        \"instance\": TypeError,\n    }),\n    (\"napi://client_id/client_secret/id/+15551231234/?channels=bad\", {\n        # Invalid channel\n        \"instance\": TypeError,\n    }),\n    (\"napi://?secret=cs&to=id,user404@example.com&type=typed\", {\n        # No id found\n        \"instance\": TypeError,\n    }),\n    (\"napi://client_id/client_secret/id/g@rb@ge/+15551235553/\", {\n        # g@rb@ge entry ignored\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    (\"napi://cid/secret/id/user1@example.com/?type=apprise-msg\", {\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    (\"notificationapi://cid/secret/id/user1@example.com\", {\n        # Support full schema:// of notificationapi://\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    (\"napi://cid/secret/id/id2/user1@example.com\", {\n        # two id's in a row\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    ((\"napi://type@cid/secret/id10/user2@example.com/\"\n      \"id5/+15551235555/id8/+15551235534\"\n      \"?reply=Chris<chris@example.com>\"), {\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    ((\"napi://type@cid/secret/abc1/user1@example.com/\"\n      \"id5/+15551235555/?from=Chris&reply=Christopher\"), {\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    ((\"napi://type@cid/secret/id/user3@example.com/\"\n      \"?from=joe@example.ca&reply=user@abc.com\"), {\n        # Set from/source\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    ((\"napi://type@cid/secret/id/user4@example.com/\"\n      \"?from=joe@example.ca&bcc=user1@yahoo.ca&cc=user2@yahoo.ca\"), {\n        # Set from/source\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n        # Our expected url(privacy=True) startswith() response:\n        \"privacy_url\": \"napi://type@c...d/s...t/\",\n    }),\n    (\"napi://?id=ci&secret=cs&to=id,user5@example.com&type=typec\", {\n        # use just kwargs\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n        # Our expected url(privacy=True) startswith() response:\n        \"privacy_url\": \"napi://typec@c...i/c...s/\",\n    }),\n    (\"napi://id?secret=cs&to=id,user5@example.com&type=typeb\", {\n        # id is pull from the host\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n        # Our expected url(privacy=True) startswith() response:\n        \"privacy_url\": \"napi://typeb@i...d/c...s/\",\n    }),\n    (\"napi://secret?id=ci&to=id,user5@example.com&type=typea\", {\n        # id pulled from kwargs still allows secret to be the\n        # next parsed entry from cli\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n        # Our expected url(privacy=True) startswith() response:\n        \"privacy_url\": \"napi://typea@c...i/s...t/\",\n    }),\n    (\"napi://?id=ci&secret=cs&type=test-type&region=eu\", {\n        # No targets specified\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n        \"notify_response\": False,\n    }),\n    (\"napi://?id=ci&secret=cs&to=id,user5@example.com&type=typec\", {\n        # bad response\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_BAD_RESPONSE,\n        \"notify_response\": False,\n    }),\n    (\"napi://user@client_id/cs2/id/user6@example.ca\"\n     \"?bcc=invalid\", {\n         # A good email with a bad Blind Carbon Copy\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs3/id/user8@example.ca\"\n     \"?cc=l2g@nuxref.com\", {\n         # A good email with Carbon Copy\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://client_id/cs3/id/user8@example.ca\"\n     \"?channels=email,sms,slack,mobile_push,web_push,inapp\", {\n         # A good email with Carbon Copy\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs4/id/user9@example.ca\"\n     \"?cc=Chris<l2g@nuxref.com>\", {\n         # A good email with Carbon Copy\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs5/id/user10@example.ca\"\n     \"?cc=invalid\", {\n         # A good email with Carbon Copy\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs6/id/user11@example.ca\"\n     \"?to=invalid\", {\n         # an invalid to email\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs7/id/chris1@example.com\", {\n        # An email with a designated to email\n        \"instance\": NotifyNotificationAPI,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs8/id1/user12@example.ca\"\n     \"?to=id,Chris<chris2@example.com>\", {\n         # An email with a full name in in To field\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs9/id2/user13@example.ca/\"\n     \"id/kris@example.com/id/chris2@example.com/id/+15552341234\"\n     \"?:token=value\", {\n         # Several emails to notify\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs10/id/user14@example.ca\"\n     \"?cc=Chris<chris10@example.com>\", {\n         # An email with a full name in cc\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs11/id/user15@example.ca\"\n     \"?cc=chris12@example.com\", {\n         # An email with a full name in cc\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs12/id/user16@example.ca\"\n     \"?bcc=Chris<chris14@example.com>\", {\n         # An email with a full name in bcc\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs13/id/user@example.ca\"\n     \"?bcc=chris13@example.com\", {\n         # An email with a full name in bcc\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs14/id/user@example.ca\"\n     \"?to=Chris<chris9@example.com>,id14\", {\n         # An email with a full name in bcc\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs15/id\"\n     \"?to=user@example.com\", {\n         # An email with a full name in bcc\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n     }),\n    (\"napi://user@client_id/cs16/id/user@example.ca\"\n     \"?template=1234&+sub=value&+sub2=value2\", {\n         # A good email with a template + substitutions\n         \"instance\": NotifyNotificationAPI,\n         \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n\n         # Our expected url(privacy=True) startswith() response:\n         \"privacy_url\": \"napi://user@c...d/c...6/\",\n     }),\n    (\"napi://user@client_id/cs17/id/user@example.ca\", {\n        \"instance\": NotifyNotificationAPI,\n        # force a failure\n        \"response\": False,\n        \"requests_response_code\": requests.codes.internal_server_error,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    (\"napi://user@client_id/cs18/id/user@example.ca\", {\n        \"instance\": NotifyNotificationAPI,\n        # throw a bizarre code forcing us to fail to look it up\n        \"response\": False,\n        \"requests_response_code\": 999,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n    (\"napi://user@client_id/cs19/id/user@example.ca\", {\n        \"instance\": NotifyNotificationAPI,\n        # Throws a series of connection and transfer exceptions when this flag\n        # is set and tests that we gracefully handle them\n        \"test_requests_exceptions\": True,\n        \"requests_response_text\": NOTIFICATIONAPI_GOOD_RESPONSE,\n    }),\n)\n\n\ndef test_plugin_napi_urls():\n    \"\"\"\n    NotifyNotificationAPI() Apprise URLs\n\n    \"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_napi_template_sms_payloads(mock_post):\n    \"\"\"NotifyNotificationAPI() Testing Template SMS Payloads.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = NOTIFICATIONAPI_GOOD_RESPONSE\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # Details\n    client_id = \"my_id\"\n    client_secret = \"my_secret\"\n    message_type = \"apprise-post\"\n    targets = \"userid/+1-555-123-4567\"\n\n    obj = Apprise.instantiate(\n        f\"napi://{message_type}@{client_id}/{client_secret}/\"\n        f\"{targets}?mode=template\")\n    assert isinstance(obj, NotifyNotificationAPI)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # delivery of message\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://api.notificationapi.com/{client_id}/sender\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload == {\n        \"type\": \"apprise-post\",\n        \"to\": {\n            \"id\": \"userid\",\n            \"number\": \"+15551234567\",\n        },\n        \"parameters\": {\n            \"appBody\": \"body\",\n            \"appTitle\": \"title\",\n            \"appType\": \"info\",\n            \"appId\": \"Apprise\",\n            \"appDescription\": \"Apprise Notifications\",\n            \"appColor\": \"#3AA3E3\",\n            \"appImageUrl\": (\n                \"https://github.com/caronc/apprise/raw/master/apprise\"\n                \"/assets/themes/default/apprise-info-72x72.png\"),\n            \"appUrl\": \"https://github.com/caronc/apprise\"},\n    }\n    headers = mock_post.call_args_list[0][1][\"headers\"]\n    assert headers == {\n            \"User-Agent\": \"Apprise\",\n            \"Content-Type\": \"application/json\",\n            \"Authorization\": \"Basic bXlfaWQ6bXlfc2VjcmV0\"}\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_napi_template_email_payloads(mock_post):\n    \"\"\"NotifyNotificationAPI() Testing Template Email Payloads.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = NOTIFICATIONAPI_GOOD_RESPONSE\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # Details\n    client_id = \"my_id_abc\"\n    client_secret = \"my_secret\"\n    message_type = \"apprise-post\"\n    targets = \"userid/test@example.ca\"\n\n    obj = Apprise.instantiate(\n        f\"napi://{message_type}@{client_id}/{client_secret}/\"\n        f\"{targets}?from=Chris<chris@example.eu>&bcc=joe@hidden.com&\"\n        f\"cc=jason@hidden.com&:customToken=customValue&mode=template\")\n    assert isinstance(obj, NotifyNotificationAPI)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # delivery of message\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://api.notificationapi.com/{client_id}/sender\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload == {\n        \"type\": \"apprise-post\",\n        \"to\": {\n                \"id\": \"userid\",\n                \"email\": \"test@example.ca\",\n        },\n        \"options\": {\n            \"email\": {\n                \"fromAddress\": \"chris@example.eu\",\n                \"fromName\": \"Chris\",\n                \"ccAddresses\": [\"jason@hidden.com\"],\n                \"bccAddresses\": [\"joe@hidden.com\"]}\n        },\n        \"parameters\": {\n            \"customToken\": \"customValue\",\n            \"appBody\": \"body\",\n            \"appTitle\": \"title\",\n            \"appType\": \"info\",\n            \"appId\": \"Apprise\",\n            \"appDescription\": \"Apprise Notifications\",\n            \"appColor\": \"#3AA3E3\",\n            \"appImageUrl\": (\n                \"https://github.com/caronc/apprise/raw/master/apprise/\"\n                \"assets/themes/default/apprise-info-72x72.png\"),\n            \"appUrl\": \"https://github.com/caronc/apprise\"\n        },\n    }\n    headers = mock_post.call_args_list[0][1][\"headers\"]\n    assert headers == {\n        \"User-Agent\": \"Apprise\",\n        \"Content-Type\": \"application/json\",\n        \"Authorization\": \"Basic bXlfaWRfYWJjOm15X3NlY3JldA==\"}\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_napi_message_payloads(mock_post):\n    \"\"\"NotifyNotificationAPI() Testing Message Payloads.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = NOTIFICATIONAPI_GOOD_RESPONSE\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # Details\n    client_id = \"my_id_abc\"\n    client_secret = \"my_secret\"\n    message_type = \"apprise-post\"\n    targets = \"userid/test@example.ca/+15551239876\"\n\n    obj = Apprise.instantiate(\n        f\"napi://{message_type}@{client_id}/{client_secret}/\"\n        f\"{targets}?from=Chris<chris@example.eu>&bcc=joe@hidden.com\"\n        f\"&mode=message\")\n    assert isinstance(obj, NotifyNotificationAPI)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # delivery of message\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://api.notificationapi.com/{client_id}/sender\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload == {\n        \"type\": \"apprise-post\",\n        \"to\": {\n            \"id\": \"userid\",\n            \"email\": \"test@example.ca\",\n            \"number\": \"+15551239876\",\n        },\n        \"email\": {\n            \"subject\": \"title\",\n            \"html\": \"body\",\n            \"senderName\": \"Chris\",\n            \"senderEmail\": \"chris@example.eu\",\n        },\n        \"options\": {\n            \"email\": {\n                \"fromAddress\": \"chris@example.eu\",\n                \"fromName\": \"Chris\",\n                \"bccAddresses\": [\"joe@hidden.com\"],\n            },\n        },\n    }\n    headers = mock_post.call_args_list[0][1][\"headers\"]\n    assert headers == {\n        \"User-Agent\": \"Apprise\",\n        \"Content-Type\": \"application/json\",\n        \"Authorization\": \"Basic bXlfaWRfYWJjOm15X3NlY3JldA==\"}\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Reversing the sms with email causes auto-detection channel to\n    # be sms instead of email\n    targets = \"userid/+15551239876/test@example.ca\"\n\n    obj = Apprise.instantiate(\n        f\"napi://{client_id}/{client_secret}/\"\n        f\"{targets}?from=Chris<chris@example.eu>&bcc=joe@hidden.com\")\n    assert isinstance(obj, NotifyNotificationAPI)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # delivery of message\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://api.notificationapi.com/{client_id}/sender\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload == {\n        \"type\": \"apprise\",\n        \"to\": {\n            \"id\": \"userid\",\n            \"number\": \"+15551239876\",\n            \"email\": \"test@example.ca\",\n        },\n        \"sms\": {\"message\": \"title\\nbody\"},\n        \"options\": {\n            \"email\": {\n                \"fromAddress\": \"chris@example.eu\",\n                \"fromName\": \"Chris\",\n                \"bccAddresses\": [\"joe@hidden.com\"]},\n        },\n    }\n\n    headers = mock_post.call_args_list[0][1][\"headers\"]\n    assert headers == {\n        \"User-Agent\": \"Apprise\",\n        \"Content-Type\": \"application/json\",\n        \"Authorization\": \"Basic bXlfaWRfYWJjOm15X3NlY3JldA==\"}\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Experiment with fixed channels:\n    obj = Apprise.instantiate(\n        f\"napi://{message_type}@{client_id}/{client_secret}/\"\n        f\"{targets}?from=Chris<chris@example.eu>&bcc=joe@hidden.com\"\n        f\"&mode=message&channels=sms,slack\")\n    assert isinstance(obj, NotifyNotificationAPI)\n    assert isinstance(obj.url(), str)\n\n    # No calls made yet\n    assert mock_post.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # delivery of message\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://api.notificationapi.com/{client_id}/sender\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload == {\n        \"type\": \"apprise-post\",\n        \"to\": {\n            \"id\": \"userid\",\n            \"email\": \"test@example.ca\",\n            \"number\": \"+15551239876\",\n        },\n        \"slack\": {\"text\": \"title\\nbody\"},\n        \"sms\": {\"message\": \"title\\nbody\"},\n        \"options\": {\n            \"email\": {\n                \"fromAddress\": \"chris@example.eu\",\n                \"fromName\": \"Chris\",\n                \"bccAddresses\": [\"joe@hidden.com\"],\n            },\n        },\n    }\n\n    headers = mock_post.call_args_list[0][1][\"headers\"]\n    assert headers == {\n        \"User-Agent\": \"Apprise\",\n        \"Content-Type\": \"application/json\",\n        \"Authorization\": \"Basic bXlfaWRfYWJjOm15X3NlY3JldA==\"}\n\n\ndef test_plugin_napi_edge_cases():\n    \"\"\"\n    NotifyNotificationAPI() Edge Cases\n\n    \"\"\"\n    client_id = \"my_id_abc\"\n    client_secret = \"my_secret\"\n    targets = [\"userid\", \"test@example.ca\", \"+15551239876\"]\n\n    # Tests case where tokens is == None\n    obj = NotifyNotificationAPI(client_id, client_secret, targets=targets)\n    assert isinstance(obj, NotifyNotificationAPI)\n    assert isinstance(obj.url(), str)\n"
  },
  {
    "path": "tests/test_plugin_notifico.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.notifico import NotifyNotifico\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"notifico://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notifico://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notifico://1234\",\n        {\n            # Just a project id provided (no message token)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notifico://abcd/ckhrjW8w672m6HG\",\n        {\n            # an invalid project id provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG\",\n        {\n            # A project id and message hook provided\n            \"instance\": NotifyNotifico,\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG?prefix=no\",\n        {\n            # Disable our prefix\n            \"instance\": NotifyNotifico,\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG?color=yes\",\n        {\n            \"instance\": NotifyNotifico,\n            \"notify_type\": \"info\",\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG?color=yes\",\n        {\n            \"instance\": NotifyNotifico,\n            \"notify_type\": \"success\",\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG?color=yes\",\n        {\n            \"instance\": NotifyNotifico,\n            \"notify_type\": \"warning\",\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG?color=yes\",\n        {\n            \"instance\": NotifyNotifico,\n            \"notify_type\": \"failure\",\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG?color=yes\",\n        {\n            \"instance\": NotifyNotifico,\n            \"notify_type\": \"invalid\",\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG?color=no\",\n        {\n            # Test our color flag by having it set to off\n            \"instance\": NotifyNotifico,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"notifico://1...4/c...G\",\n        },\n    ),\n    # Support Native URLs\n    (\n        \"https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj\",\n        {\n            \"instance\": NotifyNotifico,\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyNotifico,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyNotifico,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyNotifico,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"notifico://1234/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyNotifico,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_notifico_urls():\n    \"\"\"NotifyNotifico() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_ntfy.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport re\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nimport apprise\nfrom apprise.plugins.ntfy import NotifyNtfy, NtfyPriority\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# For testing our return response\nGOOD_RESPONSE_TEXT = {\n    \"code\": \"0\",\n    \"error\": \"success\",\n}\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"ntfy://\",\n        {\n            # Initializes okay (as cloud mode) but has no topics to notify\n            \"instance\": NotifyNtfy,\n            # invalid topics specified (nothing to notify)\n            # as a result the response type will be false\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            \"response\": False,\n        },\n    ),\n    (\n        \"ntfys://\",\n        {\n            # Initializes okay (as cloud mode) but has no topics to notify\n            \"instance\": NotifyNtfy,\n            # invalid topics specified (nothing to notify)\n            # as a result the response type will be false\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            \"response\": False,\n        },\n    ),\n    (\n        \"ntfy://:@/\",\n        {\n            # Initializes okay (as cloud mode) but has no topics to notify\n            \"instance\": NotifyNtfy,\n            # invalid topics specified (nothing to notify)\n            # as a result the response type will be false\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            \"response\": False,\n        },\n    ),\n    # No topics\n    (\n        \"ntfy://user:pass@localhost?mode=private\",\n        {\n            \"instance\": NotifyNtfy,\n            # invalid topics specified (nothing to notify)\n            # as a result the response type will be false\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            \"response\": False,\n        },\n    ),\n    # No valid topics\n    (\n        \"ntfy://user:pass@localhost/#/!/@\",\n        {\n            \"instance\": NotifyNtfy,\n            # invalid topics specified (nothing to notify)\n            # as a result the response type will be false\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            \"response\": False,\n        },\n    ),\n    # user/pass combos\n    (\n        \"ntfy://user@localhost/topic/\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://user@localhost/topic\",\n        },\n    ),\n    # Ntfy cloud mode (enforced)\n    (\n        \"ntfy://ntfy.sh/topic1/topic2/\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # No user/pass combo\n    (\n        \"ntfy://localhost/topic1/topic2/\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # A Email Testing\n    (\n        \"ntfy://localhost/topic1/?email=user@gmail.com\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Tags\n    (\n        \"ntfy://localhost/topic1/?tags=tag1,tag2,tag3\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Actions\n    (\n        \"ntfy://localhost/topic1/?actions=view%2CExample%2Chttp://www.example.com/%3Bview%2CTest%2Chttp://www.test.com/\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Delay\n    (\n        \"ntfy://localhost/topic1/?delay=3600\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Title\n    (\n        \"ntfy://localhost/topic1/?title=A%20Great%20Title\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Click\n    (\n        \"ntfy://localhost/topic1/?click=yes\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Email\n    (\n        \"ntfy://localhost/topic1/?email=user@example.com\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # No images\n    (\n        \"ntfy://localhost/topic1/?image=False\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Over-ride Image Path\n    (\n        \"ntfy://localhost/topic1/?avatar_url=ttp://localhost/test.jpg\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Attach\n    (\n        \"ntfy://localhost/topic1/?attach=http://example.com/file.jpg\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Attach with filename over-ride\n    (\n        (\n            \"ntfy://localhost/topic1/\"\n            \"?attach=http://example.com/file.jpg&filename=smoke.jpg\"\n        ),\n        {\"instance\": NotifyNtfy, \"requests_response_text\": GOOD_RESPONSE_TEXT},\n    ),\n    # Attach with bad url\n    (\n        \"ntfy://localhost/topic1/?attach=http://-%20\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Auth Token Types (tk_ gets detected as a auth=token)\n    (\n        \"ntfy://tk_abcd123456@localhost/topic1\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://t...6@localhost/topic1\",\n        },\n    ),\n    # Force an auth token since lack of tk_ prevents auto-detection\n    (\n        \"ntfy://abcd123456@localhost/topic1?auth=token\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://a...6@localhost/topic1\",\n        },\n    ),\n    # Force an auth token since lack of tk_ prevents auto-detection\n    (\n        \"ntfy://:abcd123456@localhost/topic1?auth=token\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://a...6@localhost/topic1\",\n        },\n    ),\n    # Token detection already implied when token keyword is set\n    (\n        \"ntfy://localhost/topic1?token=abc1234\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://a...4@localhost/topic1\",\n        },\n    ),\n    # Token enforced, but since a user/pass provided, only the pass is kept\n    (\n        \"ntfy://user:token@localhost/topic1?auth=token\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://t...n@localhost/topic1\",\n        },\n    ),\n    # Token mode force, but there was no token provided\n    (\n        \"ntfy://localhost/topic1?auth=token\",\n        {\n            \"instance\": NotifyNtfy,\n            # We'll out-right fail to send the notification\n            \"response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://localhost/topic1\",\n        },\n    ),\n    # Priority\n    (\n        \"ntfy://localhost/topic1/?priority=default\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ntfy://localhost/topic1\",\n        },\n    ),\n    # Priority higher\n    (\n        \"ntfy://localhost/topic1/?priority=high\",\n        {\n            \"instance\": NotifyNtfy,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # A topic and port identifier\n    (\n        \"ntfy://user:pass@localhost:8080/topic/\",\n        {\n            \"instance\": NotifyNtfy,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # A topic (using the to=)\n    (\n        \"ntfys://user:pass@localhost?to=topic\",\n        {\n            \"instance\": NotifyNtfy,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    (\n        \"https://just/a/random/host/that/means/nothing\",\n        {\n            # Nothing transpires from this\n            \"instance\": None\n        },\n    ),\n    # reference the ntfy.sh url\n    (\n        \"https://ntfy.sh?to=topic\",\n        {\n            \"instance\": NotifyNtfy,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Several topics\n    (\n        \"ntfy://user:pass@topic1/topic2/topic3/?mode=cloud\",\n        {\n            \"instance\": NotifyNtfy,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    # Several topics (but do not add ntfy.sh)\n    (\n        \"ntfy://user:pass@ntfy.sh/topic1/topic2/?mode=cloud\",\n        {\n            \"instance\": NotifyNtfy,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    (\n        \"ntfys://user:web/token@localhost/topic/?mode=invalid\",\n        {\n            # Invalid mode\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ntfys://token@localhost/topic/?auth=invalid\",\n        {\n            # Invalid Authentication type\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid hostname on localhost/private mode\n    (\n        \"ntfys://user:web@-_/topic1/topic2/?mode=private\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"ntfy://user:pass@localhost:8089/topic/topic2\",\n        {\n            \"instance\": NotifyNtfy,\n            # force a failure using basic mode\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"ntfy://user:pass@localhost:8082/topic\",\n        {\n            \"instance\": NotifyNtfy,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n    (\n        \"ntfy://user:pass@localhost:8083/topic1/topic2/\",\n        {\n            \"instance\": NotifyNtfy,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n            \"requests_response_text\": GOOD_RESPONSE_TEXT,\n        },\n    ),\n)\n\n\ndef test_plugin_ntfy_chat_urls():\n    \"\"\"NotifyNtfy() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_ntfy_attachments(mock_post):\n    \"\"\"NotifyNtfy() Attachment Checks.\"\"\"\n\n    # Prepare Mock return object\n    response = mock.Mock()\n    response.content = GOOD_RESPONSE_TEXT\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n\n    # Test how the notifications work without attachments as they use the\n    # JSON type posting instead\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Prepare our object\n    obj = apprise.Apprise.instantiate(\"ntfy://user:pass@localhost:8080/topic\")\n\n    # Send a good attachment\n    assert obj.notify(title=\"hello\", body=\"world\")\n    assert mock_post.call_count == 1\n\n    assert mock_post.call_args_list[0][0][0] == \"http://localhost:8080\"\n\n    response = json.loads(mock_post.call_args_list[0][1][\"data\"])\n    assert response[\"topic\"] == \"topic\"\n    assert response[\"title\"] == \"hello\"\n    assert response[\"message\"] == \"world\"\n    assert \"attach\" not in response\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # prepare our attachment\n    attach = apprise.AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    )\n\n    # Prepare our object\n    obj = apprise.Apprise.instantiate(\"ntfy://user:pass@localhost:8084/topic\")\n\n    # Send a good attachment\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Test our call count; includes both image and message\n    assert mock_post.call_count == 1\n\n    assert mock_post.call_args_list[0][0][0] == \"http://localhost:8084/topic\"\n\n    assert mock_post.call_args_list[0][1][\"params\"][\"message\"] == \"test\"\n    assert \"title\" not in mock_post.call_args_list[0][1][\"params\"]\n    assert (\n        mock_post.call_args_list[0][1][\"params\"][\"filename\"]\n        == \"apprise-test.gif\"\n    )\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Add another attachment so we drop into the area of the PushBullet code\n    # that sends remaining attachments (if more detected)\n    attach.add(os.path.join(TEST_VAR_DIR, \"apprise-test.png\"))\n\n    # Send our attachments\n    assert obj.notify(body=\"test\", title=\"wonderful\", attach=attach) is True\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    # Image + Message sent\n    assert mock_post.call_args_list[0][0][0] == \"http://localhost:8084/topic\"\n    assert mock_post.call_args_list[0][1][\"params\"][\"message\"] == \"test\"\n    assert mock_post.call_args_list[0][1][\"params\"][\"title\"] == \"wonderful\"\n    assert (\n        mock_post.call_args_list[0][1][\"params\"][\"filename\"]\n        == \"apprise-test.gif\"\n    )\n\n    # Image no 2 (no message)\n    assert mock_post.call_args_list[1][0][0] == \"http://localhost:8084/topic\"\n    assert \"message\" not in mock_post.call_args_list[1][1][\"params\"]\n    assert \"title\" not in mock_post.call_args_list[1][1][\"params\"]\n    assert (\n        mock_post.call_args_list[1][1][\"params\"][\"filename\"]\n        == \"apprise-test.png\"\n    )\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    attach = apprise.AppriseAttachment(path)\n    assert obj.notify(body=\"test\", attach=attach) is False\n\n    # Test our call count\n    assert mock_post.call_count == 0\n\n    # prepare our attachment\n    attach = apprise.AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    )\n\n    # Throw an exception on the first call to requests.post()\n    mock_post.return_value = None\n    for side_effect in (requests.RequestException(), OSError()):\n        mock_post.side_effect = side_effect\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_custom_ntfy_edge_cases(mock_post):\n    \"\"\"NotifyNtfy() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n    response.content = json.dumps(GOOD_RESPONSE_TEXT)\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    results = NotifyNtfy.parse_url(\n        \"ntfys://abc---,topic2,~~,,?priority=max&tags=smile,de\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == \"abc---,topic2,~~,,\"\n    assert results[\"fullpath\"] is None\n    assert results[\"path\"] is None\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"ntfys\"\n    assert results[\"url\"] == \"ntfys://abc---,topic2,~~,,\"\n    assert isinstance(results[\"qsd:\"], dict) is True\n    assert results[\"qsd\"][\"priority\"] == \"max\"\n    assert results[\"qsd\"][\"tags\"] == \"smile,de\"\n\n    instance = NotifyNtfy(**results)\n    assert isinstance(instance, NotifyNtfy)\n    assert len(instance.topics) == 2\n    assert \"abc---\" in instance.topics\n    assert \"topic2\" in instance.topics\n\n    results = NotifyNtfy.parse_url(\n        \"ntfy://localhost/topic1/\"\n        \"?attach=http://example.com/file.jpg&filename=smoke.jpg\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"/topic1/\"\n    assert results[\"path\"] == \"/topic1/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"ntfy\"\n    assert results[\"url\"] == \"ntfy://localhost/topic1/\"\n    assert results[\"attach\"] == \"http://example.com/file.jpg\"\n    assert results[\"filename\"] == \"smoke.jpg\"\n\n    instance = NotifyNtfy(**results)\n    assert isinstance(instance, NotifyNtfy)\n    assert len(instance.topics) == 1\n    assert \"topic1\" in instance.topics\n\n    assert (\n        instance.notify(\n            body=\"body\", title=\"title\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    # Test our call count\n    assert mock_post.call_count == 1\n    assert mock_post.call_args_list[0][0][0] == \"http://localhost\"\n\n    response = json.loads(mock_post.call_args_list[0][1][\"data\"])\n    assert response[\"topic\"] == \"topic1\"\n    assert response[\"message\"] == \"body\"\n    assert response[\"title\"] == \"title\"\n    assert response[\"attach\"] == \"http://example.com/file.jpg\"\n    assert response[\"filename\"] == \"smoke.jpg\"\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Markdown Support\n    results = NotifyNtfy.parse_url(\"ntfys://topic/?format=markdown\")\n    assert isinstance(results, dict)\n    instance = NotifyNtfy(**results)\n\n    assert (\n        instance.notify(\n            body=\"body\", title=\"title\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    assert mock_post.call_count == 1\n    assert mock_post.call_args_list[0][0][0] == \"https://ntfy.sh\"\n    assert \"X-Markdown\" in mock_post.call_args_list[0][1][\"headers\"]\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"requests.get\")\ndef test_plugin_ntfy_config_files(mock_post, mock_get):\n    \"\"\"NotifyNtfy() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - ntfy://localhost/topic1:\n          - priority: 1\n            tag: ntfy_int min\n          - priority: \"1\"\n            tag: ntfy_str_int min\n          - priority: min\n            tag: ntfy_str min\n\n          # This will take on normal (default) priority\n          - priority: invalid\n            tag: ntfy_invalid\n\n      - ntfy://localhost/topic2:\n          - priority: 5\n            tag: ntfy_int max\n          - priority: \"5\"\n            tag: ntfy_str_int max\n          - priority: emergency\n            tag: ntfy_str max\n          - priority: max\n            tag: ntfy_str max\n    \"\"\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value = requests.Request()\n    mock_get.return_value.status_code = requests.codes.ok\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 8 servers from that\n    # 3x min\n    # 4x max\n    # 1x invalid (so takes on normal priority)\n    assert len(ac.servers()) == 8\n    assert len(aobj) == 8\n    assert len(list(aobj.find(tag=\"min\"))) == 3\n    for s in aobj.find(tag=\"min\"):\n        assert s.priority == NtfyPriority.MIN\n\n    assert len(list(aobj.find(tag=\"max\"))) == 4\n    for s in aobj.find(tag=\"max\"):\n        assert s.priority == NtfyPriority.MAX\n\n    assert len(list(aobj.find(tag=\"ntfy_str\"))) == 3\n    assert len(list(aobj.find(tag=\"ntfy_str_int\"))) == 2\n    assert len(list(aobj.find(tag=\"ntfy_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"ntfy_invalid\"))) == 1\n    assert next(aobj.find(tag=\"ntfy_invalid\")).priority == NtfyPriority.NORMAL\n\n    # A cloud reference without any identifiers; the ntfy:// (insecure mode)\n    # is not considered during the id generation as ntfys:// is always\n    # implied\n    results = NotifyNtfy.parse_url(\"ntfy://\")\n    obj = NotifyNtfy(**results)\n    new_results = NotifyNtfy.parse_url(obj.url())\n    obj2 = NotifyNtfy(**new_results)\n    assert obj.url_id() == obj2.url_id()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_ntfy_internationalized_urls(mock_post):\n    \"\"\"NotifyNtfy() Internationalized URL Support.\"\"\"\n\n    # Prepare Mock return object\n    response = mock.Mock()\n    response.content = GOOD_RESPONSE_TEXT\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n\n    # Our input\n    title = \"My Title\"\n    body = \"My Body\"\n\n    # Google Translate promised me this just says 'Apprise Example' (I hope\n    # this is the case 🙏).  Below is a URL requiring encoding so that it\n    # can be correctly passed into an http header:\n    click = \"https://通知の例\"\n\n    # Prepare our object\n    obj = apprise.Apprise.instantiate(f\"ntfy://ntfy.sh/topic1?click={click}\")\n\n    # Send our notification\n    assert obj.notify(title=title, body=body)\n    assert mock_post.call_count == 1\n\n    assert mock_post.call_args_list[0][0][0] == \"http://ntfy.sh\"\n\n    # Verify that our International URL was correctly escaped\n    assert (\n        \"https://%25E9%2580%259A%25E7%259F%25A5%25E3%2581%25AE%25E4%25BE%258B\"\n        in mock_post.call_args_list[0][1][\"headers\"][\"X-Click\"]\n    )\n\n    # Validate that we did not obstruct our URL in anyway\n    assert apprise.Apprise.instantiate(obj.url()).url() == obj.url()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_ntfy_message_to_attach(mock_post):\n    \"\"\"NotifyNtfy() large messages converted into attachments.\"\"\"\n\n    # Prepare Mock return object\n    response = mock.Mock()\n    response.content = GOOD_RESPONSE_TEXT\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n\n    # Create a very, very big message\n    title = \"My Title\"\n    body = \"b\" * NotifyNtfy.ntfy_json_upstream_size_limit\n\n    for fmt in apprise.NOTIFY_FORMATS:\n\n        # Prepare our object\n        obj = apprise.Apprise.instantiate(\n            f\"ntfy://user:pass@localhost:8080/topic?format={fmt}\"\n        )\n\n        # Our content will actually transfer as an attachment\n        assert obj.notify(title=title, body=body)\n        assert mock_post.call_count == 1\n\n        assert (\n            mock_post.call_args_list[0][0][0] == \"http://localhost:8080/topic\"\n        )\n\n        response = mock_post.call_args_list[0][1]\n        assert \"data\" in response\n        assert response[\"data\"].decode(\"utf-8\").startswith(title)\n        assert response[\"data\"].decode(\"utf-8\").endswith(body)\n        assert \"params\" in response\n        assert \"filename\" in response[\"params\"]\n        # Our filename is automatically generated (with .txt)\n        assert re.match(\n            r\"^[a-z0-9-]+\\.txt$\", response[\"params\"][\"filename\"], re.I\n        )\n\n        # Reset our mock object\n        mock_post.reset_mock()\n"
  },
  {
    "path": "tests/test_plugin_office365.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.office365 import NotifyOffice365\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyOffice365\n    ##################################\n    (\n        \"o365://\",\n        {\n            # Missing tenant, client_id, secret, and targets!\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"o365://:@/\",\n        {\n            # invalid url\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/{targets}\".format(\n            # invalid tenant\n            tenant=\",\",\n            cid=\"ab-cd-ef-gh\",\n            aid=\"user@example.com\",\n            secret=\"abcd/123/3343/@jack/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            # Expected failure\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/{targets}\".format(\n            tenant=\"tenant\",\n            # invalid client id\n            cid=\"ab.\",\n            aid=\"user2@example.com\",\n            secret=\"abcd/123/3343/@jack/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            # Expected failure\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"o365://{tenant}/{cid}/{secret}/{targets}\".format(\n            # email not required if mode is set to self\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            secret=\"abcd/123/3343/@jack/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"expires_in\": 2000,\n                \"access_token\": \"abcd1234\",\n                \"mail\": \"user@example.ca\",\n            },\n        },\n    ),\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/{targets}\".format(\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            aid=\"user@example.edu\",\n            secret=\"abcd/123/3343/@jack/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"expires_in\": 2000,\n                \"access_token\": \"abcd1234\",\n                # For 'From:' Lookup\n                \"mail\": \"user@example.ca\",\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"azure://user@example.edu/t...t/a...h/****/email1@test.ca/\"\n            ),\n            \"force_debug\": True,\n        },\n    ),\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/{targets}\".format(\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            # Source can also be Object ID\n            aid=\"hg-fe-dc-ba\",\n            secret=\"abcd/123/3343/@jack/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"expires_in\": 2000,\n                \"access_token\": \"abcd1234\",\n                \"mail\": \"user@example.ca\",\n                \"displayName\": \"John\",\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"azure://hg-fe-dc-ba/t...t/a...h/****/email1@test.ca/\"\n            ),\n        },\n    ),\n    # ObjectID Specified, but no targets\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/\".format(\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            # Source can also be Object ID\n            aid=\"hg-fe-dc-ba\",\n            secret=\"abcd/123/3343/@jack/test\",\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"expires_in\": 2000,\n                \"access_token\": \"abcd1234\",\n                \"mail\": \"user@example.ca\",\n            },\n            # No emails detected\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"azure://hg-fe-dc-ba/t...t/a...h/****\",\n        },\n    ),\n    # ObjectID Specified, but no targets\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/\".format(\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            # Source can also be Object ID\n            aid=\"hg-fe-dc-ba\",\n            secret=\"abcd/123/3343/@jack/test\",\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"expires_in\": 2000,\n                \"access_token\": \"abcd1234\",\n                \"userPrincipalName\": \"user@example.ca\",\n            },\n            # No emails detected\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"azure://hg-fe-dc-ba/t...t/a...h/****\",\n        },\n    ),\n    # test our arguments\n    (\n        \"o365://_/?oauth_id={cid}&oauth_secret={secret}&tenant={tenant}\"\n        \"&to={targets}&from={aid}\".format(\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            aid=\"user@example.ca\",\n            secret=\"abcd/123/3343/@jack/test\",\n            targets=\"email1@test.ca\",\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"expires_in\": 2000,\n                \"access_token\": \"abcd1234\",\n                \"mail\": \"user@example.ca\",\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"azure://user@example.ca/t...t/a...h/****/email1@test.ca/\"\n            ),\n        },\n    ),\n    # Test invalid JSON (no tenant defaults to email domain)\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/{targets}\".format(\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            aid=\"user@example.com\",\n            secret=\"abcd/123/3343/@jack/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # invalid JSON response\n            \"requests_response_text\": \"{\",\n            \"notify_response\": False,\n        },\n    ),\n    # No Targets specified\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}\".format(\n            tenant=\"tenant\",\n            cid=\"ab-cd-ef-gh\",\n            aid=\"user@example.com\",\n            secret=\"abcd/123/3343/@jack/test\",\n        ),\n        {\n            # We're valid and good to go\n            \"instance\": NotifyOffice365,\n            # There were no targets to notify; so we use our own email\n            \"requests_response_text\": {\n                \"expires_in\": 2000,\n                \"access_token\": \"abcd1234\",\n                \"userPrincipalName\": \"user@example.ca\",\n            },\n        },\n    ),\n    (\n        \"o365://{aid}/{tenant}/{cid}/{secret}/{targets}\".format(\n            tenant=\"tenant\",\n            cid=\"zz-zz-zz-zz\",\n            aid=\"user@example.com\",\n            secret=\"abcd/abc/dcba/@john/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            \"instance\": NotifyOffice365,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"o365://{tenant}:{aid}/{cid}/{secret}/{targets}\".format(\n            tenant=\"tenant\",\n            cid=\"01-12-23-34\",\n            aid=\"user@example.com\",\n            secret=\"abcd/321/4321/@test/test\",\n            targets=\"/\".join([\"email1@test.ca\"]),\n        ),\n        {\n            \"instance\": NotifyOffice365,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_office365_urls():\n    \"\"\"NotifyOffice365() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_office365_general(mock_get, mock_post):\n    \"\"\"NotifyOffice365() General Testing.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    email = \"user@example.net\"\n    tenant = \"ff-gg-hh-ii-jj\"\n    client_id = \"aa-bb-cc-dd-ee\"\n    secret = \"abcd/1234/abcd@ajd@/test\"\n    targets = \"target@example.com\"\n\n    # Prepare Mock return object\n    payload = {\n        \"token_type\": \"Bearer\",\n        \"expires_in\": 6000,\n        \"access_token\": \"abcd1234\",\n        # For 'From:' Lookup\n        \"mail\": \"abc@example.ca\",\n        # For our Draft Email ID:\n        \"id\": \"draft-id-no\",\n    }\n    response = mock.Mock()\n    response.content = dumps(payload)\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n    mock_get.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(f\"o365://{email}/{tenant}/{secret}/{targets}\")\n\n    assert isinstance(obj, NotifyOffice365)\n\n    # Test our URL generation\n    assert isinstance(obj.url(), str)\n\n    # Test our notification\n    assert obj.notify(title=\"title\", body=\"test\") is True\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"o365://{email}/{tenant}/{client_id}/{secret}/{targets}\"\n        \"?bcc={bcc}&cc={cc}\".format(\n            tenant=tenant,\n            email=email,\n            client_id=client_id,\n            secret=secret,\n            targets=targets,\n            # Test the cc and bcc list (use good and bad email)\n            cc=\"Chuck Norris cnorris@yahoo.ca, Sauron@lotr.me, invalid@!\",\n            bcc=\"Bruce Willis bwillis@hotmail.com, Frodo@lotr.me invalid@!\",\n        )\n    )\n\n    assert isinstance(obj, NotifyOffice365)\n\n    # Test our URL generation\n    assert isinstance(obj.url(), str)\n\n    # Test our notification\n    assert obj.notify(title=\"title\", body=\"test\") is True\n\n    with pytest.raises(TypeError):\n        # No secret\n        NotifyOffice365(\n            email=email,\n            client_id=client_id,\n            tenant=tenant,\n            secret=None,\n            targets=None,\n        )\n\n    # One of the targets are invalid\n    obj = NotifyOffice365(\n        email=email,\n        client_id=client_id,\n        tenant=tenant,\n        secret=secret,\n        targets=(\"Management abc@gmail.com\", \"garbage\"),\n    )\n    # Test our notification (this will work and only notify abc@gmail.com)\n    assert obj.notify(title=\"title\", body=\"test\") is True\n\n    # all of the targets are invalid\n    obj = NotifyOffice365(\n        email=email,\n        client_id=client_id,\n        tenant=tenant,\n        secret=secret,\n        targets=(\"invalid\", \"garbage\"),\n    )\n\n    # Test our notification (which will fail because of no entries)\n    assert obj.notify(title=\"title\", body=\"test\") is False\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_office365_authentication(mock_get, mock_post):\n    \"\"\"NotifyOffice365() Authentication Testing.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    tenant = \"ff-gg-hh-ii-jj\"\n    email = \"user@example.net\"\n    client_id = \"aa-bb-cc-dd-ee\"\n    secret = \"abcd/1234/abcd@ajd@/test\"\n    targets = \"target@example.com\"\n\n    # Prepare Mock return object\n    authentication_okay = {\n        \"token_type\": \"Bearer\",\n        \"expires_in\": 6000,\n        \"access_token\": \"abcd1234\",\n    }\n    authentication_failure = {\n        \"error\": \"invalid_scope\",\n        \"error_description\": \"AADSTS70011: Blah... Blah Blah... Blah\",\n        \"error_codes\": [70011],\n        \"timestamp\": \"2020-01-09 02:02:12Z\",\n        \"trace_id\": \"255d1aef-8c98-452f-ac51-23d051240864\",\n        \"correlation_id\": \"fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7\",\n    }\n    response = mock.Mock()\n    response.content = dumps(authentication_okay)\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n    mock_get.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        f\"azure://{email}/{tenant}/{client_id}/{secret}/{targets}\"\n    )\n\n    assert isinstance(obj, NotifyOffice365)\n\n    # Authenticate\n    assert obj.authenticate() is True\n\n    # We're already authenticated\n    assert obj.authenticate() is True\n\n    # Expire our token\n    obj.token_expiry = datetime.now()\n\n    # Re-authentiate\n    assert obj.authenticate() is True\n\n    # Change our response\n    response.status_code = 400\n\n    # We'll fail to send a notification now...\n    assert obj.notify(title=\"title\", body=\"test\") is False\n\n    # Expire our token\n    obj.token_expiry = datetime.now()\n\n    # Set a failure response\n    response.content = dumps(authentication_failure)\n\n    # We will fail to authenticate at this point\n    assert obj.authenticate() is False\n\n    # Notifications will also fail in this case\n    assert obj.notify(title=\"title\", body=\"test\") is False\n\n    # We will fail to authenticate with invalid data\n\n    invalid_auth_entries = authentication_okay.copy()\n    invalid_auth_entries[\"expires_in\"] = \"garbage\"\n    response.content = dumps(invalid_auth_entries)\n    response.status_code = requests.codes.ok\n    assert obj.authenticate() is False\n\n    invalid_auth_entries[\"expires_in\"] = None\n    response.content = dumps(invalid_auth_entries)\n    assert obj.authenticate() is False\n\n    invalid_auth_entries[\"expires_in\"] = \"\"\n    response.content = dumps(invalid_auth_entries)\n    assert obj.authenticate() is False\n\n    del invalid_auth_entries[\"expires_in\"]\n    response.content = dumps(invalid_auth_entries)\n    assert obj.authenticate() is False\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_office365_queries(mock_post, mock_get, mock_put):\n    \"\"\"NotifyOffice365() General Queries.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    source = \"abc-1234-object-id\"\n    tenant = \"ff-gg-hh-ii-jj\"\n    client_id = \"aa-bb-cc-dd-ee\"\n    secret = \"abcd/1234/abcd@ajd@/test\"\n    targets = \"target@example.ca\"\n\n    # Prepare Mock return object\n    payload = {\n        \"token_type\": \"Bearer\",\n        \"expires_in\": 6000,\n        \"access_token\": \"abcd1234\",\n        # For 'From:' Lookup (email)\n        \"mail\": \"user@example.edu\",\n        # For 'From:' Lookup (name)\n        \"displayName\": \"John\",\n        # For our Draft Email ID:\n        \"id\": \"draft-id-no\",\n        # For FIle Uploads\n        \"uploadUrl\": \"https://my.url.path/\",\n    }\n\n    okay_response = mock.Mock()\n    okay_response.content = dumps(payload)\n    okay_response.status_code = requests.codes.ok\n    mock_post.return_value = okay_response\n    mock_put.return_value = okay_response\n\n    bad_response = mock.Mock()\n    bad_response.content = dumps(payload)\n    bad_response.status_code = requests.codes.forbidden\n\n    # Assign our GET a bad response so we fail to look up the user\n    mock_get.return_value = bad_response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        f\"azure://{source}/{tenant}/{client_id}{secret}/{targets}\"\n    )\n\n    assert isinstance(obj, NotifyOffice365)\n\n    # We can still send a notification even if we can't look up the email\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://graph.microsoft.com/v1.0/users/abc-1234-object-id/sendMail\"\n    )\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n    assert payload == {\n        \"message\": {\n            \"subject\": \"title\",\n            \"body\": {\n                \"contentType\": \"HTML\",\n                \"content\": \"body\",\n            },\n            \"toRecipients\": [\n                {\"emailAddress\": {\"address\": \"target@example.ca\"}}\n            ],\n        },\n        \"saveToSentItems\": \"true\",\n    }\n    mock_post.reset_mock()\n\n    # Now test a case where we just couldn't get any email details from the\n    # payload returned\n\n    # Prepare Mock return object\n    temp_payload = {\n        \"token_type\": \"Bearer\",\n        \"expires_in\": 6000,\n        \"access_token\": \"abcd1234\",\n        # For our Draft Email ID:\n        \"id\": \"draft-id-no\",\n        # For FIle Uploads\n        \"uploadUrl\": \"https://my.url.path/\",\n    }\n\n    bad_response.content = dumps(temp_payload)\n    bad_response.status_code = requests.codes.okay\n    mock_get.return_value = bad_response\n\n    obj = Apprise.instantiate(\n        f\"azure://{source}/{tenant}/{client_id}{secret}/{targets}\"\n    )\n\n    assert isinstance(obj, NotifyOffice365)\n\n    # We can still send a notification even if we can't look up the email\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n\n@mock.patch(\"requests.put\")\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_office365_attachments(mock_post, mock_get, mock_put):\n    \"\"\"NotifyOffice365() Attachments.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    source = \"user@example.net\"\n    tenant = \"ff-gg-hh-ii-jj\"\n    client_id = \"aa-bb-cc-dd-ee\"\n    secret = \"abcd/1234/abcd@ajd@/test\"\n    targets = \"target@example.com\"\n\n    # Prepare Mock return object\n    payload = {\n        \"token_type\": \"Bearer\",\n        \"expires_in\": 6000,\n        \"access_token\": \"abcd1234\",\n        # For 'From:' Lookup\n        \"mail\": \"user@example.edu\",\n        # For our Draft Email ID:\n        \"id\": \"draft-id-no\",\n        # For FIle Uploads\n        \"uploadUrl\": \"https://my.url.path/\",\n    }\n    okay_response = mock.Mock()\n    okay_response.content = dumps(payload)\n    okay_response.status_code = requests.codes.ok\n    mock_post.return_value = okay_response\n    mock_get.return_value = okay_response\n    mock_put.return_value = okay_response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        f\"azure://{source}/{tenant}/{client_id}{secret}/{targets}\"\n    )\n\n    assert isinstance(obj, NotifyOffice365)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token\"\n    )\n    assert (\n        mock_post.call_args_list[0][1][\"headers\"].get(\"Content-Type\")\n        == \"application/x-www-form-urlencoded\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/sendMail\"\n    )\n    assert (\n        mock_post.call_args_list[1][1][\"headers\"].get(\"Content-Type\")\n        == \"application/json\"\n    )\n    mock_post.reset_mock()\n\n    # Test Authentication Failure\n    obj = Apprise.instantiate(\n        \"azure://{source}/{tenant}/{client_id}{secret}/{targets}\".format(\n            client_id=client_id,\n            tenant=tenant,\n            source=\"object-id-requiring-lookup\",\n            secret=secret,\n            targets=targets,\n        )\n    )\n\n    bad_response = mock.Mock()\n    bad_response.content = dumps(payload)\n    bad_response.status_code = requests.codes.forbidden\n    mock_post.return_value = bad_response\n\n    assert isinstance(obj, NotifyOffice365)\n    # Authentication will fail\n    assert (\n        obj.notify(\n            body=\"auth-fail\", title=\"title\", notify_type=NotifyType.INFO\n        )\n        is False\n    )\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token\"\n    )\n    mock_post.reset_mock()\n\n    #\n    # Test invalid attachment\n    #\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        f\"azure://{source}/{tenant}/{client_id}{secret}/{targets}\"\n    )\n\n    assert isinstance(obj, NotifyOffice365)\n\n    mock_post.return_value = okay_response\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n    assert mock_post.call_count == 0\n    mock_post.reset_mock()\n\n    with mock.patch(\"base64.b64encode\", side_effect=OSError()):\n        # We can't send the message if we fail to parse the data\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n    assert mock_post.call_count == 0\n    mock_post.reset_mock()\n\n    #\n    # Test case where we can't authenticate\n    #\n    obj = Apprise.instantiate(\n        f\"azure://{source}/{tenant}/{client_id}{secret}/{targets}\"\n    )\n\n    # Force a smaller attachment size forcing us to create an attachment\n    obj.outlook_attachment_inline_max = 50\n\n    assert isinstance(obj, NotifyOffice365)\n\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    mock_post.return_value = bad_response\n    assert obj.upload_attachment(attach[0], \"id\") is False\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token\"\n    )\n\n    mock_post.reset_mock()\n\n    mock_post.side_effect = (okay_response, bad_response)\n    mock_post.return_value = None\n    assert obj.upload_attachment(attach[0], \"id\") is False\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/\"\n        + \"message/id/attachments/createUploadSession\"\n    )\n\n    mock_post.reset_mock()\n    # Return our status\n    mock_post.side_effect = None\n\n    # Prepare Mock return object\n    payload_no_upload_url = {\n        \"token_type\": \"Bearer\",\n        \"expires_in\": 6000,\n        \"access_token\": \"abcd1234\",\n        # For 'From:' Lookup\n        \"mail\": \"user@example.edu\",\n        # For our Draft Email ID:\n        \"id\": \"draft-id-no\",\n    }\n    tmp_response = mock.Mock()\n    tmp_response.content = dumps(payload_no_upload_url)\n    tmp_response.status_code = requests.codes.ok\n    mock_post.return_value = tmp_response\n\n    assert obj.upload_attachment(attach[0], \"id\") is False\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/\"\n        + \"message/id/attachments/createUploadSession\"\n    )\n\n    mock_post.reset_mock()\n    # Return our status\n    mock_post.side_effect = None\n    mock_post.return_value = okay_response\n\n    obj = Apprise.instantiate(\n        f\"azure://{source}/{tenant}/{client_id}{secret}/{targets}\"\n    )\n\n    # Force a smaller attachment size forcing us to create an attachment\n    obj.outlook_attachment_inline_max = 50\n\n    assert isinstance(obj, NotifyOffice365)\n\n    # We now have to prepare sepparate session attachments using draft emails\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title-test\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Large Attachments\n    assert mock_post.call_count == 4\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/messages\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/\"\n        + \"message/draft-id-no/attachments/createUploadSession\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/sendMail\"\n    )\n    mock_post.reset_mock()\n\n    #\n    # Handle another case where can't upload the attachment at all\n    #\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    bad_attach = AppriseAttachment(path)\n    assert obj.upload_attachment(bad_attach[0], \"id\") is False\n\n    mock_post.reset_mock()\n    #\n    # Handle test case where we can't send the draft email after everything\n    # has been prepared\n    #\n    mock_post.return_value = None\n    mock_post.side_effect = (okay_response, okay_response, bad_response)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title-test\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    assert mock_post.call_count == 3\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/messages\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/\"\n        + \"message/draft-id-no/attachments/createUploadSession\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/sendMail\"\n    )\n    mock_post.reset_mock()\n    mock_post.side_effect = None\n    mock_post.return_value = okay_response\n\n    #\n    # Handle test case where we can not upload chunks\n    #\n    mock_put.return_value = bad_response\n\n    # We now have to prepare sepparate session attachments using draft emails\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title-no-chunk\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/messages\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/\"\n        + \"message/draft-id-no/attachments/createUploadSession\"\n    )\n\n    mock_put.return_value = okay_response\n    mock_post.reset_mock()\n\n    # Prepare Mock return object\n    payload_missing_id = {\n        \"token_type\": \"Bearer\",\n        \"expires_in\": 6000,\n        \"access_token\": \"abcd1234\",\n        # For 'From:' Lookup\n        \"mail\": \"user@example.edu\",\n        # For FIle Uploads\n        \"uploadUrl\": \"https://my.url.path/\",\n    }\n    temp_response = mock.Mock()\n    temp_response.content = dumps(payload_missing_id)\n    temp_response.status_code = requests.codes.ok\n    mock_post.return_value = temp_response\n\n    # We could not acquire an attachment id, so we'll fail to send our\n    # notification\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title-test\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Large Attachments\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://graph.microsoft.com/v1.0/users/user@example.net/messages\"\n    )\n\n    mock_post.reset_mock()\n\n    # Reset attachment size\n    obj.outlook_attachment_inline_max = 50 * 1024 * 1024\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # already authenticated\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == f\"https://graph.microsoft.com/v1.0/users/{source}/sendMail\"\n    )\n    mock_post.reset_mock()\n"
  },
  {
    "path": "tests/test_plugin_onesignal.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.one_signal import NotifyOneSignal\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"onesignal://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"onesignal://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"onesignal://apikey/\",\n        {\n            # no app id specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"onesignal://appid@%20%20/\",\n        {\n            # invalid apikey\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/playerid/?lang=X\",\n        {\n            # invalid language id (must be 2 characters)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/\",\n        {\n            # No targets specified; we will initialize but not notify anything\n            \"instance\": NotifyOneSignal,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/playerid\",\n        {\n            # Valid playerid\n            \"instance\": NotifyOneSignal,\n            \"privacy_url\": \"onesignal://a...d@a...y/playerid\",\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/player\",\n        {\n            # Valid player id\n            \"instance\": NotifyOneSignal,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/@user?image=no\",\n        {\n            # Valid userid, no image\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/user@email.com/#seg/player/@user/%20/a\",\n        {\n            # Valid email, valid playerid, valid user, invalid entry (%20),\n            # and too short of an entry (a)\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey?to=#segment,playerid\",\n        {\n            # Test to=\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/#segment/@user/?batch=yes\",\n        {\n            # Test batch=\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/#segment/@user/?batch=no\",\n        {\n            # Test batch=\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://templateid:appid@apikey/playerid\",\n        {\n            # Test Template ID\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/playerid/?lang=es&subtitle=Sub\",\n        {\n            # Test Language and Subtitle Over-ride\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://?apikey=abc&template=tp&app=123&to=playerid\",\n        {\n            # Test Kwargs\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        (\n            \"onesignal://?apikey=abc&template=tp&app=123&to=playerid&body=no\"\n            \"&:key1=val1&:key2=val2\"\n        ),\n        {\n            # Test Kwargs\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        (\n            \"onesignal://?apikey=abc&template=tp&app=123&to=playerid&body=no\"\n            \"&+key1=val1&+key2=val2\"\n        ),\n        {\n            # Test Kwargs\n            \"instance\": NotifyOneSignal,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/#segment/playerid/\",\n        {\n            \"instance\": NotifyOneSignal,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"onesignal://appid@apikey/#segment/playerid/\",\n        {\n            \"instance\": NotifyOneSignal,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_onesignal_urls():\n    \"\"\"NotifyOneSignal() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_onesignal_edge_cases():\n    \"\"\"NotifyOneSignal() Batch Validation.\"\"\"\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/#segment/@user/playerid/user@email.com\"\n        \"/?batch=yes\"\n    )\n    # Validate that it loaded okay\n    assert isinstance(obj, NotifyOneSignal)\n\n    # all 4 types defined; but even in a batch mode, they can not be\n    # sent in one submission\n    assert len(obj) == 4\n\n    #\n    # Users\n    #\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/@user1/@user2/@user3/@user4/?batch=yes\"\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # We can lump these together - no problem\n    assert len(obj) == 1\n\n    # Same query, but no batch mode set\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/@user1/@user2/@user3/@user4/?batch=no\"\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # Individual queries\n    assert len(obj) == 4\n\n    #\n    # Segments\n    #\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/#segment1/#seg2/#seg3/#seg4/?batch=yes\"\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # We can lump these together - no problem\n    assert len(obj) == 1\n\n    # Same query, but no batch mode set\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/#segment1/#seg2/#seg3/#seg4/?batch=no\"\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # Individual queries\n    assert len(obj) == 4\n\n    #\n    # Player ID's\n    #\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/pid1/pid2/pid3/pid4/?batch=yes\"\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # We can lump these together - no problem\n    assert len(obj) == 1\n\n    # Same query, but no batch mode set\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/pid1/pid2/pid3/pid4/?batch=no\"\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # Individual queries\n    assert len(obj) == 4\n\n    #\n    # Emails\n    #\n    emails = (\"abc@yahoo.ca\", \"def@yahoo.ca\", \"ghi@yahoo.ca\", \"jkl@yahoo.ca\")\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/{}/?batch=yes\".format(\"/\".join(emails))\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # We can lump these together - no problem\n    assert len(obj) == 1\n\n    # Same query, but no batch mode set\n    obj = Apprise.instantiate(\n        \"onesignal://appid@apikey/{}/?batch=no\".format(\"/\".join(emails))\n    )\n    assert isinstance(obj, NotifyOneSignal)\n\n    # Individual queries\n    assert len(obj) == 4\n\n    #\n    # Mixed\n    #\n    emails = (\"abc@yahoo.ca\", \"def@yahoo.ca\", \"ghi@yahoo.ca\", \"jkl@yahoo.ca\")\n    users = (\"@user1\", \"@user2\", \"@user3\", \"@user4\")\n    players = (\"player1\", \"player2\", \"player3\", \"player4\")\n    segments = (\"#seg1\", \"#seg2\", \"#seg3\", \"#seg4\")\n\n    path = \"{}/{}/{}/{}\".format(\n        \"/\".join(emails),\n        \"/\".join(users),\n        \"/\".join(players),\n        \"/\".join(segments),\n    )\n\n    obj = Apprise.instantiate(f\"onesignal://appid@apikey/{path}/?batch=yes\")\n    assert isinstance(obj, NotifyOneSignal)\n\n    # We can lump these together - no problem\n    assert len(obj) == 4\n\n    # Same query, but no batch mode set\n    obj = Apprise.instantiate(f\"onesignal://appid@apikey/{path}/?batch=no\")\n    assert isinstance(obj, NotifyOneSignal)\n\n    # Individual queries\n    assert len(obj) == 16\n\n    # custom must be a dictionary\n    with pytest.raises(TypeError):\n        NotifyOneSignal(\n            app=\"appid\", apikey=\"key\", targets=[\"@user\"], custom=\"not-a-dict\"\n        )\n\n    # postback must be a dictionary\n    with pytest.raises(TypeError):\n        NotifyOneSignal(\n            app=\"appid\",\n            apikey=\"key\",\n            targets=[\"@user\"],\n            custom=[],\n            postback=\"not-a-dict\",\n        )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_onesignal_notifications(mock_post):\n    \"\"\"OneSignal() Notifications Support.\"\"\"\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Load URL with Template\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/?:key1=value1&+key3=value3\"\n    )\n\n    # Validate that it loaded okay\n    assert isinstance(instance, NotifyOneSignal)\n\n    response = instance.notify(\"hello world\")\n    assert response is True\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.onesignal.com/notifications\"\n    )\n\n    details = mock_post.call_args_list[0]\n    payload = loads(details[1][\"data\"])\n\n    assert payload == {\n        \"app_id\": \"appid\",\n        \"contents\": {\"en\": \"hello world\"},\n        \"content_available\": True,\n        \"template_id\": \"templateid\",\n        \"custom_data\": {\"key1\": \"value1\"},\n        \"data\": {\"key3\": \"value3\"},\n        \"large_icon\": (\n            \"https://github.com/caronc/apprise\"\n            \"/raw/master/apprise/assets/themes/default/apprise-info-72x72.png\"\n        ),\n        \"small_icon\": (\n            \"https://github.com/caronc/apprise\"\n            \"/raw/master/apprise/assets/themes/default/apprise-info-32x32.png\"\n        ),\n        \"include_external_user_ids\": [\"@user\"],\n    }\n\n    mock_post.reset_mock()\n\n    # Load URL with Template and disable body\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/?contents=no\"\n    )\n\n    # Validate that it loaded okay\n    assert isinstance(instance, NotifyOneSignal)\n\n    response = instance.notify(\"hello world\")\n    assert response is True\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.onesignal.com/notifications\"\n    )\n\n    details = mock_post.call_args_list[0]\n    payload = loads(details[1][\"data\"])\n\n    assert payload == {\n        \"app_id\": \"appid\",\n        \"content_available\": True,\n        \"template_id\": \"templateid\",\n        \"large_icon\": (\n            \"https://github.com/caronc/apprise\"\n            \"/raw/master/apprise/assets/themes/default/apprise-info-72x72.png\"\n        ),\n        \"small_icon\": (\n            \"https://github.com/caronc/apprise\"\n            \"/raw/master/apprise/assets/themes/default/apprise-info-32x32.png\"\n        ),\n        \"include_external_user_ids\": [\"@user\"],\n    }\n\n    # Now set a title\n    mock_post.reset_mock()\n\n    response = instance.notify(\"hello world\", title=\"mytitle\")\n\n    assert response is True\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.onesignal.com/notifications\"\n    )\n\n    details = mock_post.call_args_list[0]\n    payload = loads(details[1][\"data\"])\n\n    assert payload == {\n        \"app_id\": \"appid\",\n        \"headings\": {\"en\": \"mytitle\"},\n        \"content_available\": True,\n        \"template_id\": \"templateid\",\n        \"large_icon\": (\n            \"https://github.com/caronc/apprise\"\n            \"/raw/master/apprise/assets/themes/default/apprise-info-72x72.png\"\n        ),\n        \"small_icon\": (\n            \"https://github.com/caronc/apprise\"\n            \"/raw/master/apprise/assets/themes/default/apprise-info-32x32.png\"\n        ),\n        \"include_external_user_ids\": [\"@user\"],\n    }\n\n    # Test without decoding parameters\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/\"\n        \"?:par=b64:eyJhIjoxLCJiIjoyfQ==&decode=no\"\n    )\n    assert isinstance(instance, NotifyOneSignal) and instance.custom_data == {\n        \"par\": \"b64:eyJhIjoxLCJiIjoyfQ==\"\n    }\n\n    # Now same with loading parameters\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/\"\n        \"?:par=b64:eyJhIjoxLCJiIjoyfQ==&decode=yes\"\n    )\n    assert isinstance(instance, NotifyOneSignal) and instance.custom_data == {\n        \"par\": {\"a\": 1, \"b\": 2}\n    }\n\n    # Test bad data in general\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/?:par=garbage1&decode=yes\"\n    )\n    assert isinstance(instance, NotifyOneSignal) and instance.custom_data == {\n        \"par\": \"garbage1\"\n    }\n\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/\"\n        \"?:par=b64:garbage2&decode=yes\"\n    )\n    assert isinstance(instance, NotifyOneSignal) and instance.custom_data == {\n        \"par\": \"b64:garbage2\"\n    }\n\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/\"\n        \"?:par=b64:garbage3==&decode=yes\"\n    )\n    assert isinstance(instance, NotifyOneSignal) and instance.custom_data == {\n        \"par\": \"b64:garbage3==\"\n    }\n\n    # Now same with not-base64 parameters\n    instance = Apprise.instantiate(\n        \"onesignal://templateid:appid@apikey/@user/\"\n        \"?:par=eyJhIjoxLCJiIjoyfQ==&:par2=123&decode=yes\"\n    )\n    assert isinstance(instance, NotifyOneSignal) and instance.custom_data == {\n        \"par\": \"eyJhIjoxLCJiIjoyfQ==\",\n        \"par2\": \"123\",\n    }\n\n    # Test incorrect base64 parameters. Second one has incorrect padding\n    url = (\n        \"onesignal://templateid:appid@apikey/@user/\"\n        \"?:par=b64:1234=&:par2=b64:eyJhIjoxLCJiIjoyfQ&\"\n        \":par3=b64:eyJhIjoxLCJiIjoyfQ==&decode=yes\"\n    )\n    instance = Apprise.instantiate(url)\n    assert isinstance(instance, NotifyOneSignal) and instance.custom_data == {\n        \"par\": \"b64:1234=\",\n        \"par2\": \"b64:eyJhIjoxLCJiIjoyfQ\",\n        \"par3\": {\"a\": 1, \"b\": 2},\n    }\n"
  },
  {
    "path": "tests/test_plugin_opsgenie.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nimport apprise\nfrom apprise.plugins.opsgenie import (\n    NotifyOpsgenie,\n    NotifyType,\n    OpsgeniePriority,\n)\n\nlogging.disable(logging.CRITICAL)\n\n# a test UUID we can use\nUUID4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n\nOPSGENIE_GOOD_RESPONSE = dumps({\n    \"result\": \"Request will be processed\",\n    \"took\": 0.204,\n    \"requestId\": \"43a29c5c-3dbf-4fa4-9c26-f4f71023e120\",\n})\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"opsgenie://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"opsgenie://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"opsgenie://%20%20/\",\n        {\n            # invalid apikey specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"opsgenie://apikey/user/?region=xx\",\n        {\n            # invalid region id\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"opsgenie://user@apikey/\",\n        {\n            # No targets specified; this is allowed\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.WARNING,\n            # Bad response returned\n            \"requests_response_text\": \"{\",\n            # We will not be successful sending the notice\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"opsgenie://apikey/\",\n        {\n            # No targets specified; this is allowed\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/user\",\n        {\n            # Valid user\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n            \"privacy_url\": \"opsgenie://a...y/%40user\",\n        },\n    ),\n    (\n        \"opsgenie://apikey/@user?region=eu\",\n        {\n            # European Region\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/@user?entity=A%20Entity\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/@user?alias=An%20Alias\",\n        {\n            # Assign an alias\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    # Bad Action\n    (\n        \"opsgenie://apikey/@user?action=invalid\",\n        {\n            # Assign an entity\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"opsgenie://from@apikey/@user?:invalid=note\",\n        {\n            # Assign an entity\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"opsgenie://apikey/@user?:warning=invalid\",\n        {\n            # Assign an entity\n            \"instance\": TypeError,\n        },\n    ),\n    # Creates an index entry\n    (\n        \"opsgenie://apikey/@user?entity=index&action=new\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    # Now action it\n    (\n        \"opsgenie://apikey/@user?entity=index&action=acknowledge\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.SUCCESS,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://from@apikey/@user?entity=index&action=note\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.SUCCESS,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://from@apikey/@user?entity=index&action=note\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.SUCCESS,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n            \"response\": False,\n            \"requests_response_code\": 500,\n        },\n    ),\n    (\n        \"opsgenie://apikey/@user?entity=index&action=close\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.SUCCESS,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/@user?entity=index&action=delete\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.SUCCESS,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    # map info messages to generate a new message\n    (\n        \"opsgenie://apikey/@user?entity=index2&:info=new\",\n        {\n            # Assign an entity\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.INFO,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://joe@apikey/@user?priority=p3\",\n        {\n            # Assign our priority\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/?tags=comma,separated\",\n        {\n            # Test our our 'tags' (tag is reserved in Apprise) but not 'tags'\n            # Also test the fact we do not need to define a target\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/@user?priority=invalid\",\n        {\n            # Invalid priority (loads using default)\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/user@email.com/#team/*sche/^esc/%20/a\",\n        {\n            # Valid user (email), valid schedule, Escalated ID,\n            # an invalid entry (%20), and too short of an entry (a)\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        f\"opsgenie://apikey/@{UUID4}/#{UUID4}/*{UUID4}/^{UUID4}/\",\n        {\n            # similar to the above, except we use the UUID's\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    # Same link as before but @ missing at the front causing an ambigious\n    # lookup however the entry is treated a though a @ was in front (user)\n    (\n        f\"opsgenie://apikey/{UUID4}/#{UUID4}/*{UUID4}/^{UUID4}/\",\n        {\n            # similar to the above, except we use the UUID's\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey?to=#team,user&+key=value&+type=override\",\n        {\n            # Test to= and details (key/value pair) also override 'type'\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/#team/@user/?batch=yes\",\n        {\n            # Test batch=\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/#team/@user/?batch=no\",\n        {\n            # Test batch=\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://?apikey=abc&to=user\",\n        {\n            # Test Kwargs\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"opsgenie://apikey/#team/user/\",\n        {\n            \"instance\": NotifyOpsgenie,\n            # throw a bizarre code forcing us to fail to look it up\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"opsgenie://apikey/#topic1/device/\",\n        {\n            \"instance\": NotifyOpsgenie,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": OPSGENIE_GOOD_RESPONSE,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_opsgenie_urls(tmpdir):\n    \"\"\"NotifyOpsgenie() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all(str(tmpdir))\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_opsgenie_config_files(mock_post):\n    \"\"\"NotifyOpsgenie() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - opsgenie://apikey/user:\n          - priority: 1\n            tag: opsgenie_int low\n          - priority: \"1\"\n            tag: opsgenie_str_int low\n          - priority: \"p1\"\n            tag: opsgenie_pstr_int low\n          - priority: low\n            tag: opsgenie_str low\n\n          # This will take on moderate (default) priority\n          - priority: invalid\n            tag: opsgenie_invalid\n\n      - opsgenie://apikey2/user2:\n          - priority: 5\n            tag: opsgenie_int emerg\n          - priority: \"5\"\n            tag: opsgenie_str_int emerg\n          - priority: \"p5\"\n            tag: opsgenie_pstr_int emerg\n          - priority: emergency\n            tag: opsgenie_str emerg\n    \"\"\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = OPSGENIE_GOOD_RESPONSE\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 9 servers from that\n    # 4x low\n    # 4x emerg\n    # 1x invalid (so takes on normal priority)\n    assert len(ac.servers()) == 9\n    assert len(aobj) == 9\n    assert len(list(aobj.find(tag=\"low\"))) == 4\n    for s in aobj.find(tag=\"low\"):\n        assert s.priority == OpsgeniePriority.LOW\n\n    assert len(list(aobj.find(tag=\"emerg\"))) == 4\n    for s in aobj.find(tag=\"emerg\"):\n        assert s.priority == OpsgeniePriority.EMERGENCY\n\n    assert len(list(aobj.find(tag=\"opsgenie_str\"))) == 2\n    assert len(list(aobj.find(tag=\"opsgenie_str_int\"))) == 2\n    assert len(list(aobj.find(tag=\"opsgenie_pstr_int\"))) == 2\n    assert len(list(aobj.find(tag=\"opsgenie_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"opsgenie_invalid\"))) == 1\n    assert (\n        next(aobj.find(tag=\"opsgenie_invalid\")).priority\n        == OpsgeniePriority.NORMAL\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_opsgenie_edge_case(mock_post):\n    \"\"\"NotifyOpsgenie() Edge Cases.\"\"\"\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = OPSGENIE_GOOD_RESPONSE\n\n    instance = apprise.Apprise.instantiate(\"opsgenie://apikey\")\n    assert isinstance(instance, NotifyOpsgenie)\n\n    assert len(instance.store.keys()) == 0\n    assert instance.notify(\"test\", \"key\", NotifyType.FAILURE) is True\n    assert len(instance.store.keys()) == 1\n\n    # Again just causes same index to get over-written\n    assert instance.notify(\"test\", \"key\", NotifyType.FAILURE) is True\n    assert len(instance.store.keys()) == 1\n    assert \"a62f2225bf\" in instance.store\n\n    # Assign it garbage\n    instance.store[\"a62f2225bf\"] = \"garbage\"\n    # This causes an internal check to fail where the keys are expected to be\n    # as a list (this one is now a string)\n    # content self corrects and things are fine\n    assert instance.notify(\"test\", \"key\", NotifyType.FAILURE) is True\n    assert len(instance.store.keys()) == 1\n\n    # new key is new index\n    assert instance.notify(\"test\", \"key2\", NotifyType.FAILURE) is True\n    assert len(instance.store.keys()) == 2\n"
  },
  {
    "path": "tests/test_plugin_pagerduty.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.pagerduty import NotifyPagerDuty\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pagerduty://\",\n        {\n            # No Access Token or Integration/Routing Key specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://%20@%20/\",\n        {\n            # invalid Access Token and Integration/Routing Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://%20/\",\n        {\n            # invalid Access Token; no Integration/Routing Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://%20@abcd/\",\n        {\n            # Invalid Integration/Routing Key (but valid Access Token)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey/%20\",\n        {\n            # bad source\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey/mysource/%20\",\n        {\n            # bad component\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey?region=invalid\",\n        {\n            # invalid region\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey?severity=invalid\",\n        {\n            # invalid severity\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey\",\n        {\n            # minimum requirements met\n            \"instance\": NotifyPagerDuty,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pagerduty://****@****/A...e/N...n?\",\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey?image=no\",\n        {\n            # minimum requirements met and disable images\n            \"instance\": NotifyPagerDuty,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey?region=eu\",\n        {\n            # european region\n            \"instance\": NotifyPagerDuty,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey?severity=critical\",\n        {\n            # Severity over-ride\n            \"instance\": NotifyPagerDuty,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey?severity=err\",\n        {\n            # Severity over-ride (short-form)\n            \"instance\": NotifyPagerDuty,\n        },\n    ),\n    # Custom values\n    (\n        \"pagerduty://myroutekey@myapikey?+key=value&+key2=value2\",\n        {\n            # minimum requirements and support custom key/value pairs\n            \"instance\": NotifyPagerDuty,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey/mysource/mycomponent\",\n        {\n            # a valid url\n            \"instance\": NotifyPagerDuty,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pagerduty://****@****/m...e/m...t?\",\n        },\n    ),\n    (\n        \"pagerduty://routekey@apikey/ms/mc?group=mygroup&class=myclass\",\n        {\n            # class/group testing\n            \"instance\": NotifyPagerDuty,\n        },\n    ),\n    (\n        (\n            \"pagerduty://?integrationkey=r&apikey=a&source=s&component=c\"\n            \"&group=g&class=c&image=no&click=http://localhost\"\n        ),\n        {\n            # all parameters\n            \"instance\": NotifyPagerDuty\n        },\n    ),\n    (\n        \"pagerduty://somerkey@someapikey/bizarre/code\",\n        {\n            \"instance\": NotifyPagerDuty,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pagerduty://myroutekey@myapikey/mysource/mycomponent\",\n        {\n            \"instance\": NotifyPagerDuty,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pagerduty_urls():\n    \"\"\"NotifyPagerDuty() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pagerduty_notify_type_is_string(mock_post):\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = \"\"\n    mock_post.return_value = response\n\n    obj = Apprise.instantiate(\"pagerduty://myroutekey@myapikey\")\n    assert isinstance(obj, NotifyPagerDuty)\n\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n    assert mock_post.call_count == 1\n\n    call = mock_post.call_args_list[0]\n    # PagerDuty uses JSON payload\n    payload = call[1].get(\"data\") or call[1].get(\"json\")\n    payload_text = payload if isinstance(payload, str) else str(payload)\n\n    assert \"NotifyType.\" not in payload_text\n"
  },
  {
    "path": "tests/test_plugin_pagertree.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.pagertree import NotifyPagerTree\n\nlogging.disable(logging.CRITICAL)\n\n# a test UUID we can use\nINTEGRATION_ID = \"int_xxxxxxxxxxx\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pagertree://\",\n        {\n            # Missing Integration ID\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid Integration ID\n    (\n        \"pagertree://%s\" % (\"+\" * 24),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Minimum requirements met\n    (\n        f\"pagertree://{INTEGRATION_ID}\",\n        {\n            \"instance\": NotifyPagerTree,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pagertree://i...x?\",\n        },\n    ),\n    # change the integration id\n    (\n        f\"pagertree://{INTEGRATION_ID}?integration=int_yyyyyyyyyy\",\n        {\n            \"instance\": NotifyPagerTree,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pagertree://i...y?\",\n        },\n    ),\n    # entries specified on the URL will over-ride the host (integration id)\n    (\n        f\"pagertree://{INTEGRATION_ID}?id=int_zzzzzzzzzz\",\n        {\n            \"instance\": NotifyPagerTree,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pagertree://i...z?\",\n        },\n    ),\n    # Integration ID + bad url\n    (\n        \"pagertree://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"pagertree://{INTEGRATION_ID}\",\n        {\n            \"instance\": NotifyPagerTree,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        f\"pagertree://{INTEGRATION_ID}\",\n        {\n            \"instance\": NotifyPagerTree,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        f\"pagertree://{INTEGRATION_ID}\",\n        {\n            \"instance\": NotifyPagerTree,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        f\"pagertree://{INTEGRATION_ID}?urgency=low\",\n        {\n            # urgency override\n            \"instance\": NotifyPagerTree,\n        },\n    ),\n    (\n        f\"pagertree://?id={INTEGRATION_ID}&urgency=low\",\n        {\n            # urgency override and id= (for integration)\n            \"instance\": NotifyPagerTree,\n        },\n    ),\n    (\n        f\"pagertree://{INTEGRATION_ID}?tags=production,web\",\n        {\n            # tags\n            \"instance\": NotifyPagerTree,\n        },\n    ),\n    (\n        f\"pagertree://{INTEGRATION_ID}?action=resolve&thirdparty=123\",\n        {\n            # test resolve\n            \"instance\": NotifyPagerTree,\n        },\n    ),\n    # Custom values\n    (\n        f\"pagertree://{INTEGRATION_ID}?+pagertree-token=123&:env=prod&-m=v\",\n        {\n            # minimum requirements and support custom key/value pairs\n            \"instance\": NotifyPagerTree,\n        },\n    ),\n)\n\n\ndef test_plugin_pagertree_urls():\n    \"\"\"NotifyPagerTree() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pagertree_general(mock_post):\n    \"\"\"NotifyPagerTree() General Checks.\"\"\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Invalid thirdparty id\n    with pytest.raises(TypeError):\n        NotifyPagerTree(integration=INTEGRATION_ID, thirdparty=\"   \")\n"
  },
  {
    "path": "tests/test_plugin_parse_platform.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.parseplatform import NotifyParsePlatform\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"parsep://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # API Key + bad url\n    (\n        \"parsep://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # APIkey; no app_id or master_key\n    (\n        \"parsep://%s\" % (\"a\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # APIkey; no master_key\n    (\n        \"parsep://app_id@%s\" % (\"a\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # APIkey; no app_id\n    (\n        \"parseps://:master_key@%s\" % (\"a\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # app_id + master_key (using arguments=)\n    (\n        \"parseps://localhost?app_id={}&master_key={}\".format(\n            \"a\" * 32, \"d\" * 32\n        ),\n        {\n            \"instance\": NotifyParsePlatform,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"parseps://a...a:d...d@localhost\",\n        },\n    ),\n    # Set a device id + custom port\n    (\n        \"parsep://app_id:master_key@localhost:8080?device=ios\",\n        {\n            \"instance\": NotifyParsePlatform,\n        },\n    ),\n    # invalid device id\n    (\n        \"parsep://app_id:master_key@localhost?device=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Normal Query\n    (\n        \"parseps://app_id:master_key@localhost\",\n        {\n            \"instance\": NotifyParsePlatform,\n        },\n    ),\n    (\n        \"parseps://app_id:master_key@localhost\",\n        {\n            \"instance\": NotifyParsePlatform,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"parseps://app_id:master_key@localhost\",\n        {\n            \"instance\": NotifyParsePlatform,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"parseps://app_id:master_key@localhost\",\n        {\n            \"instance\": NotifyParsePlatform,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_parse_platform_urls():\n    \"\"\"NotifyParsePlatform() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_plivo.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.plivo import NotifyPlivo\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"plivo://\",\n        {\n            # No hostname/apikey specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"plivo://{}@{}/15551232000\".format(\"a\" * 10, \"a\" * 25),\n        {\n            # invalid auth id\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"plivo://{}@{}/15551232000\".format(\"a\" * 25, \"a\" * 10),\n        {\n            # invalid token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"plivo://{}@{}/123\".format(\"a\" * 25, \"a\" * 40),\n        {\n            # invalid phone number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"plivo://{}@{}/abc\".format(\"a\" * 25, \"a\" * 40),\n        {\n            # invalid phone number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"plivo://{}@{}/15551231234\".format(\"a\" * 25, \"b\" * 40),\n        {\n            # target phone number becomes who we text too; all is good\n            \"instance\": NotifyPlivo,\n        },\n    ),\n    (\n        \"plivo://{}@{}/15551232000/abcd\".format(\"a\" * 25, \"a\" * 40),\n        {\n            # invalid target phone number\n            \"instance\": NotifyPlivo,\n            # Notify will fail because it couldn't send to anyone\n            \"response\": False,\n        },\n    ),\n    (\n        \"plivo://{}@{}/15551232000/123\".format(\"a\" * 25, \"a\" * 40),\n        {\n            # invalid target phone number\n            \"instance\": NotifyPlivo,\n            # Notify will fail because it couldn't send to anyone\n            \"response\": False,\n        },\n    ),\n    (\n        \"plivo://{}@{}/?from=15551233000&to=15551232000&batch=yes\".format(\n            \"a\" * 25, \"a\" * 40\n        ),\n        {\n            # reference to to= and from=\n            \"instance\": NotifyPlivo,\n        },\n    ),\n    (\n        \"plivo://?id={}&token={}&from=15551233000&to=15551232000\".format(\n            \"a\" * 25, \"a\" * 40\n        ),\n        {\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"plivo://a...a@a...a/+15551233000/+15551232000\",\n            # reference to to= and from=\n            \"instance\": NotifyPlivo,\n        },\n    ),\n    (\n        \"plivo://15551232123?id={}&token={}&from=15551233000\"\n        \"&to=15551232000\".format(\"a\" * 25, \"a\" * 40),\n        {\n            # reference to to= and from=\n            \"instance\": NotifyPlivo,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"plivo://a...a@a...a/+15551233000/+15551232123\",\n        },\n    ),\n    (\n        \"plivo://{}@{}/15551232000\".format(\"a\" * 25, \"a\" * 40),\n        {\n            \"instance\": NotifyPlivo,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"plivo://{}@{}/15551232000\".format(\"a\" * 25, \"a\" * 40),\n        {\n            \"instance\": NotifyPlivo,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_plivo_urls():\n    \"\"\"NotifyPlivo() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_popcorn_notify.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.popcorn_notify import NotifyPopcornNotify\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"popcorn://\",\n        {\n            # No hostname/apikey specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"popcorn://{}/18001231234\".format(\"_\" * 9),\n        {\n            # invalid apikey\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"popcorn://{}/1232348923489234923489234289-32423\".format(\"a\" * 9),\n        {\n            # invalid phone number\n            \"instance\": NotifyPopcornNotify,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"popcorn://{}/abc\".format(\"b\" * 9),\n        {\n            # invalid email\n            \"instance\": NotifyPopcornNotify,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"popcorn://{}/15551232000/user@example.com\".format(\"c\" * 9),\n        {\n            # value phone and email\n            \"instance\": NotifyPopcornNotify,\n        },\n    ),\n    (\n        \"popcorn://{}/15551232000/user@example.com?batch=yes\".format(\"w\" * 9),\n        {\n            # value phone and email with batch mode set\n            \"instance\": NotifyPopcornNotify,\n        },\n    ),\n    (\n        \"popcorn://{}/?to=15551232000\".format(\"w\" * 9),\n        {\n            # reference to to=\n            \"instance\": NotifyPopcornNotify,\n        },\n    ),\n    (\n        \"popcorn://{}/15551232000\".format(\"x\" * 9),\n        {\n            \"instance\": NotifyPopcornNotify,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"popcorn://{}/15551232000\".format(\"y\" * 9),\n        {\n            \"instance\": NotifyPopcornNotify,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"popcorn://{}/15551232000\".format(\"z\" * 9),\n        {\n            \"instance\": NotifyPopcornNotify,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_popcorn_notify_urls():\n    \"\"\"NotifyPopcornNotify() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_prowl.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nimport apprise\nfrom apprise.plugins.prowl import NotifyProwl, ProwlPriority\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"prowl://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # bad url\n    (\n        \"prowl://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid API Key\n    (\n        \"prowl://%s\" % (\"a\" * 20),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Provider Key\n    (\n        \"prowl://{}/{}\".format(\"a\" * 40, \"b\" * 40),\n        {\n            \"instance\": NotifyProwl,\n        },\n    ),\n    # Invalid Provider Key\n    (\n        \"prowl://{}/{}\".format(\"a\" * 40, \"b\" * 20),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # APIkey; no device\n    (\n        \"prowl://%s\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n        },\n    ),\n    # API Key\n    (\n        \"prowl://%s\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # API Key + priority setting\n    (\n        \"prowl://%s?priority=high\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n        },\n    ),\n    # API Key + invalid priority setting\n    (\n        \"prowl://%s?priority=invalid\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n        },\n    ),\n    # API Key + priority setting (empty)\n    (\n        \"prowl://%s?priority=\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n        },\n    ),\n    # API Key + No Provider Key (empty)\n    (\n        \"prowl://%s///\" % (\"w\" * 40),\n        {\n            \"instance\": NotifyProwl,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"prowl://w...w/\",\n        },\n    ),\n    # API Key + Provider Key\n    (\n        \"prowl://{}/{}\".format(\"a\" * 40, \"b\" * 40),\n        {\n            \"instance\": NotifyProwl,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"prowl://a...a/b...b\",\n        },\n    ),\n    # API Key + with image\n    (\n        \"prowl://%s\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n        },\n    ),\n    (\n        \"prowl://%s\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"prowl://%s\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"prowl://%s\" % (\"a\" * 40),\n        {\n            \"instance\": NotifyProwl,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_prowl():\n    \"\"\"NotifyProwl() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_prowl_edge_cases():\n    \"\"\"NotifyProwl() Edge Cases.\"\"\"\n    # Initializes the plugin with an invalid apikey\n    with pytest.raises(TypeError):\n        NotifyProwl(apikey=None)\n    # Whitespace also acts as an invalid apikey value\n    with pytest.raises(TypeError):\n        NotifyProwl(apikey=\"  \")\n\n    # Whitespace also acts as an invalid provider key\n    with pytest.raises(TypeError):\n        NotifyProwl(apikey=\"abcd\", providerkey=object())\n    with pytest.raises(TypeError):\n        NotifyProwl(apikey=\"abcd\", providerkey=\"  \")\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_prowl_config_files(mock_post):\n    \"\"\"NotifyProwl() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - prowl://{}:\n          - priority: -2\n            tag: prowl_int low\n          - priority: \"-2\"\n            tag: prowl_str_int low\n          - priority: low\n            tag: prowl_str low\n\n          # This will take on moderate (default) priority\n          - priority: invalid\n            tag: prowl_invalid\n\n      - prowl://{}:\n          - priority: 2\n            tag: prowl_int emerg\n          - priority: \"2\"\n            tag: prowl_str_int emerg\n          - priority: emergency\n            tag: prowl_str emerg\n    \"\"\".format(\"a\" * 40, \"b\" * 40)\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 7 servers from that\n    # 3x low\n    # 3x emerg\n    # 1x invalid (so takes on normal priority)\n    assert len(ac.servers()) == 7\n    assert len(aobj) == 7\n    assert len(list(aobj.find(tag=\"low\"))) == 3\n    for s in aobj.find(tag=\"low\"):\n        assert s.priority == ProwlPriority.LOW\n\n    assert len(list(aobj.find(tag=\"emerg\"))) == 3\n    for s in aobj.find(tag=\"emerg\"):\n        assert s.priority == ProwlPriority.EMERGENCY\n\n    assert len(list(aobj.find(tag=\"prowl_str\"))) == 2\n    assert len(list(aobj.find(tag=\"prowl_str_int\"))) == 2\n    assert len(list(aobj.find(tag=\"prowl_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"prowl_invalid\"))) == 1\n    assert (\n        next(aobj.find(tag=\"prowl_invalid\")).priority == ProwlPriority.NORMAL\n    )\n"
  },
  {
    "path": "tests/test_plugin_pushbullet.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment\nfrom apprise.plugins.pushbullet import NotifyPushBullet\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pbul://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pbul://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # APIkey\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # APIkey; but support attachment response\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"file_name\": \"cat.jpeg\",\n                \"file_type\": \"image/jpeg\",\n                \"file_url\": \"http://file_url\",\n                \"upload_url\": \"http://upload_url\",\n            },\n        },\n    ),\n    # APIkey; attachment testing that isn't an image type\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"file_name\": \"test.pdf\",\n                \"file_type\": \"application/pdf\",\n                \"file_url\": \"http://file_url\",\n                \"upload_url\": \"http://upload_url\",\n            },\n        },\n    ),\n    # APIkey; attachment testing were expected entry in payload is missing\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # Test what happens if a batch send fails to return a messageCount\n            \"requests_response_text\": {\n                \"file_name\": \"test.pdf\",\n                \"file_type\": \"application/pdf\",\n                \"file_url\": \"http://file_url\",\n                # upload_url missing\n            },\n            # Our Notification calls associated with attachments will fail:\n            \"attach_response\": False,\n        },\n    ),\n    # API Key + channel\n    (\n        \"pbul://%s/#channel/\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # API Key + channel (via to=\n    (\n        \"pbul://%s/?to=#channel\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # API Key + 2 channels\n    (\n        \"pbul://%s/#channel1/#channel2\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pbul://a...a/\",\n            \"check_attachments\": False,\n        },\n    ),\n    # API Key + device\n    (\n        \"pbul://%s/device/\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # API Key + 2 devices\n    (\n        \"pbul://%s/device1/device2/\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # API Key + email\n    (\n        \"pbul://%s/user@example.com/\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # API Key + 2 emails\n    (\n        \"pbul://%s/user@example.com/abc@def.com/\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # API Key + Combo\n    (\n        \"pbul://%s/device/#channel/user@example.com/\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            \"check_attachments\": False,\n        },\n    ),\n    # ,\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n            \"check_attachments\": False,\n        },\n    ),\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            \"check_attachments\": False,\n        },\n    ),\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n            \"check_attachments\": False,\n        },\n    ),\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n            \"check_attachments\": False,\n        },\n    ),\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            \"check_attachments\": False,\n        },\n    ),\n    (\n        \"pbul://%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushBullet,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n            \"check_attachments\": False,\n        },\n    ),\n)\n\n\ndef test_plugin_pushbullet_urls():\n    \"\"\"NotifyPushBullet() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pushbullet_attachments(mock_post):\n    \"\"\"NotifyPushBullet() Attachment Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    access_token = \"t\" * 32\n\n    # Prepare Mock return object\n    response = mock.Mock()\n    response.content = dumps({\n        \"file_name\": \"cat.jpg\",\n        \"file_type\": \"image/jpeg\",\n        \"file_url\": \"https://dl.pushb.com/abc/cat.jpg\",\n        \"upload_url\": \"https://upload.pushbullet.com/abcd123\",\n    }).encode(\"utf-8\")\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n\n    # prepare our attachment\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Test our markdown\n    obj = Apprise.instantiate(f\"pbul://{access_token}/?format=markdown\")\n\n    # Send a good attachment\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Test our call count\n    assert mock_post.call_count == 4\n    # Image Prep\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.pushbullet.com/v2/upload-request\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://upload.pushbullet.com/abcd123\"\n    )\n    # Message\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://api.pushbullet.com/v2/pushes\"\n    )\n    # Image Send\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://api.pushbullet.com/v2/pushes\"\n    )\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Add another attachment so we drop into the area of the PushBullet code\n    # that sends remaining attachments (if more detected)\n    attach.add(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our attachments\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Test our call count\n    assert mock_post.call_count == 7\n    # Image Prep\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.pushbullet.com/v2/upload-request\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://upload.pushbullet.com/abcd123\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://api.pushbullet.com/v2/upload-request\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://upload.pushbullet.com/abcd123\"\n    )\n    # Message\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://api.pushbullet.com/v2/pushes\"\n    )\n    # Image Send\n    assert (\n        mock_post.call_args_list[5][0][0]\n        == \"https://api.pushbullet.com/v2/pushes\"\n    )\n    assert (\n        mock_post.call_args_list[6][0][0]\n        == \"https://api.pushbullet.com/v2/pushes\"\n    )\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    attach = AppriseAttachment(path)\n    assert obj.notify(body=\"test\", attach=attach) is False\n\n    # Test our call count\n    assert mock_post.call_count == 0\n\n    # prepare our attachment\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.content = dumps({\n        \"file_name\": \"cat.jpg\",\n        \"file_type\": \"image/jpeg\",\n        \"file_url\": \"https://dl.pushb.com/abc/cat.jpg\",\n        \"upload_url\": \"https://upload.pushbullet.com/abcd123\",\n    }).encode(\"utf-8\")\n    bad_response.status_code = requests.codes.internal_server_error\n    bad_response.headers = {}\n\n    # Prepare a bad response\n    bad_json_response = mock.Mock()\n    bad_json_response.content = b\"}\"\n    bad_json_response.status_code = requests.codes.ok\n    bad_json_response.headers = {}\n\n    # Throw an exception on the first call to requests.post()\n    for side_effect in (\n            requests.RequestException(), OSError(), [bad_response]):\n        mock_post.reset_mock()\n        mock_post.side_effect = side_effect\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Throw an exception on the second call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.reset_mock()\n        mock_post.side_effect = [response, side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Throw an exception on the third call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.reset_mock()\n        mock_post.side_effect = [response, response, side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Throw an exception on the forth call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.reset_mock()\n        mock_post.side_effect = [response, response, response, side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n    # Test case where we don't get a valid response back\n    mock_post.side_effect = None\n    mock_post.return_value = bad_json_response\n\n    # We'll fail because of an invalid json object\n    assert obj.send(body=\"test\", attach=attach) is False\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_pushbullet_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyPushBullet() Edge Cases.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    accesstoken = \"a\" * 32\n\n    # Support strings\n    recipients = \"#chan1,#chan2,device,user@example.com,,,\"\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n    mock_post.content = b\"\"\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_get.content = b\"\"\n\n    # Invalid Access Token\n    with pytest.raises(TypeError):\n        NotifyPushBullet(accesstoken=None)\n    with pytest.raises(TypeError):\n        NotifyPushBullet(accesstoken=\"     \")\n\n    obj = NotifyPushBullet(accesstoken=accesstoken, targets=recipients)\n    assert isinstance(obj, NotifyPushBullet) is True\n    assert len(obj.targets) == 4\n\n    obj = NotifyPushBullet(accesstoken=accesstoken)\n    assert isinstance(obj, NotifyPushBullet) is True\n    # Default is to send to all devices, so there will be a\n    # recipient here\n    assert len(obj.targets) == 1\n\n    obj = NotifyPushBullet(accesstoken=accesstoken, targets=set())\n    assert isinstance(obj, NotifyPushBullet) is True\n    # Default is to send to all devices, so there will be a\n    # recipient here\n    assert len(obj.targets) == 1\n"
  },
  {
    "path": "tests/test_plugin_pushdeer.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.pushdeer import NotifyPushDeer\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pushdeer://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pushdeers://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pushdeer://localhost/{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyPushDeer,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushdeer://localhost/{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyPushDeer,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"pushdeer://localhost:80/{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyPushDeer,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushdeer://localhost:80/{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyPushDeer,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"pushdeer://{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyPushDeer,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"pushdeer://{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyPushDeer,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushdeer_urls():\n    \"\"\"NotifyPushDeer() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pushdeer_general(mock_post):\n    \"\"\"NotifyPushDeer() General Checks.\"\"\"\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Variation Initializations\n    obj = Apprise.instantiate(\"pushdeer://localhost/pushKey\")\n    assert isinstance(obj, NotifyPushDeer)\n    assert isinstance(obj.url(), str)\n\n    # Send Notification\n    assert obj.send(body=\"test\") is True\n\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"http://localhost:80/message/push?pushkey=pushKey\"\n    )\n"
  },
  {
    "path": "tests/test_plugin_pushed.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.pushed import NotifyPushed\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pushed://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Application Key Only\n    (\n        \"pushed://%s\" % (\"a\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid URL\n    (\n        \"pushed://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Application Key+Secret\n    (\n        \"pushed://{}/{}\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n        },\n    ),\n    # Application Key+Secret + channel\n    (\n        \"pushed://{}/{}/#channel/\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n        },\n    ),\n    # Application Key+Secret + channel (via to=)\n    (\n        \"pushed://{}/{}?to=channel\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushed://a...a/****/\",\n        },\n    ),\n    # Application Key+Secret + dropped entry\n    (\n        \"pushed://{}/{}/dropped_value/\".format(\"a\" * 32, \"a\" * 64),\n        {\n            # No entries validated is a fail\n            \"instance\": TypeError,\n        },\n    ),\n    # Application Key+Secret + 2 channels\n    (\n        \"pushed://{}/{}/#channel1/#channel2\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n        },\n    ),\n    # Application Key+Secret + User Pushed ID\n    (\n        \"pushed://{}/{}/@ABCD/\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n        },\n    ),\n    # Application Key+Secret + 2 devices\n    (\n        \"pushed://{}/{}/@ABCD/@DEFG/\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n        },\n    ),\n    # Application Key+Secret + Combo\n    (\n        \"pushed://{}/{}/@ABCD/#channel\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n        },\n    ),\n    # ,\n    (\n        \"pushed://{}/{}\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"pushed://{}/{}\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushed://{}/{}\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"pushed://{}/{}\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"pushed://{}/{}\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushed://{}/{}/#channel\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushed://{}/{}/@user\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushed://{}/{}\".format(\"a\" * 32, \"a\" * 64),\n        {\n            \"instance\": NotifyPushed,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushed_urls():\n    \"\"\"NotifyPushed() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_pushed_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyPushed() Edge Cases.\"\"\"\n\n    # Chat ID\n    recipients = \"@ABCDEFG, @DEFGHIJ, #channel, #channel2\"\n\n    # Some required input\n    app_key = \"ABCDEFG\"\n    app_secret = \"ABCDEFG\"\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n\n    # No application Key specified\n    with pytest.raises(TypeError):\n        NotifyPushed(\n            app_key=None,\n            app_secret=app_secret,\n            recipients=None,\n        )\n\n    with pytest.raises(TypeError):\n        NotifyPushed(\n            app_key=\"  \",\n            app_secret=app_secret,\n            recipients=None,\n        )\n    # No application Secret specified\n    with pytest.raises(TypeError):\n        NotifyPushed(\n            app_key=app_key,\n            app_secret=None,\n            recipients=None,\n        )\n\n    with pytest.raises(TypeError):\n        NotifyPushed(\n            app_key=app_key,\n            app_secret=\"   \",\n        )\n\n    # recipients list set to (None) is perfectly fine; in this case it will\n    # notify the App\n    obj = NotifyPushed(\n        app_key=app_key,\n        app_secret=app_secret,\n        recipients=None,\n    )\n    assert isinstance(obj, NotifyPushed) is True\n    assert len(obj.channels) == 0\n    assert len(obj.users) == 0\n\n    obj = NotifyPushed(\n        app_key=app_key,\n        app_secret=app_secret,\n        targets=recipients,\n    )\n    assert isinstance(obj, NotifyPushed) is True\n    assert len(obj.channels) == 2\n    assert len(obj.users) == 2\n\n    # Prepare Mock to fail\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    mock_get.return_value.status_code = requests.codes.internal_server_error\n"
  },
  {
    "path": "tests/test_plugin_pushjet.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.pushjet import NotifyPushjet\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pjet://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"pjets://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"pjet://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    #  You must specify a secret key\n    (\n        \"pjet://%s\" % (\"a\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # The proper way to log in\n    (\n        \"pjet://user:pass@localhost/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushjet,\n        },\n    ),\n    # The proper way to log in\n    (\n        \"pjets://localhost/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushjet,\n        },\n    ),\n    # Specify your own server with login (secret= MUST be provided)\n    (\n        \"pjet://user:pass@localhost?secret=%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushjet,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pjet://user:****@localhost\",\n        },\n    ),\n    # Specify your own server with port\n    (\n        \"pjets://localhost:8080/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushjet,\n        },\n    ),\n    (\n        \"pjets://localhost:8080/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushjet,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"pjets://localhost:4343/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushjet,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pjet://localhost:8081/%s\" % (\"a\" * 32),\n        {\n            \"instance\": NotifyPushjet,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushjet_urls():\n    \"\"\"NotifyPushjet() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_pushjet_edge_cases():\n    \"\"\"NotifyPushjet() Edge Cases.\"\"\"\n\n    # No application Key specified\n    with pytest.raises(TypeError):\n        NotifyPushjet(secret_key=None)\n\n    with pytest.raises(TypeError):\n        NotifyPushjet(secret_key=\"  \")\n"
  },
  {
    "path": "tests/test_plugin_pushme.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.pushme import NotifyPushMe\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pushme://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pushme://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Token specified\n    (\n        \"pushme://%s\" % (\"a\" * 6),\n        {\n            \"instance\": NotifyPushMe,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushme://a...a/\",\n        },\n    ),\n    # Token specified\n    (\n        \"pushme://?token=%s&status=yes\" % (\"b\" * 6),\n        {\n            \"instance\": NotifyPushMe,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushme://b...b/\",\n        },\n    ),\n    # Status setting\n    (\n        \"pushme://?token=%s&status=no\" % (\"b\" * 6),\n        {\n            \"instance\": NotifyPushMe,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushme://b...b/\",\n        },\n    ),\n    # Status setting\n    (\n        \"pushme://?token=%s&status=True\" % (\"b\" * 6),\n        {\n            \"instance\": NotifyPushMe,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushme://b...b/\",\n        },\n    ),\n    # Token specified\n    (\n        \"pushme://?push_key=%s&status=no\" % (\"p\" * 6),\n        {\n            \"instance\": NotifyPushMe,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushme://p...p/\",\n        },\n    ),\n    (\n        \"pushme://%s\" % (\"c\" * 6),\n        {\n            \"instance\": NotifyPushMe,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"pushme://%s\" % (\"d\" * 7),\n        {\n            \"instance\": NotifyPushMe,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushme://%s\" % (\"e\" * 8),\n        {\n            \"instance\": NotifyPushMe,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushme_urls():\n    \"\"\"NotifyPushMe() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_pushover.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nimport apprise\nfrom apprise.plugins.pushover import NotifyPushover, PushoverPriority\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pover://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # bad url\n    (\n        \"pover://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # APIkey; no user\n    (\n        \"pover://%s\" % (\"a\" * 30),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # API Key + custom sound setting\n    (\n        \"pover://{}@{}?sound=mysound\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + valid alternate sound picked\n    (\n        \"pover://{}@{}?sound=spacealarm\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + valid url_title with url\n    (\n        \"pover://{}@{}?url=my-url&url_title=title\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + Valid User\n    (\n        \"pover://{}@{}\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # API Key + Valid User + 1 Device\n    (\n        \"pover://{}@{}/DEVICE\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + Valid User + 1 Device (via to=)\n    (\n        \"pover://{}@{}?to=DEVICE\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + Valid User + 2 Devices\n    (\n        \"pover://{}@{}/DEVICE1/Device-with-dash/\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pover://u...u@a...a\",\n        },\n    ),\n    # API Key + Valid User + invalid device\n    (\n        \"pover://{}@{}/{}/\".format(\"u\" * 30, \"a\" * 30, \"d\" * 30),\n        {\n            \"instance\": NotifyPushover,\n            # Notify will return False since there is a bad device in our list\n            \"response\": False,\n        },\n    ),\n    # API Key + Valid User + device + invalid device\n    (\n        \"pover://{}@{}/DEVICE1/{}/\".format(\"u\" * 30, \"a\" * 30, \"d\" * 30),\n        {\n            \"instance\": NotifyPushover,\n            # Notify will return False since there is a bad device in our list\n            \"response\": False,\n        },\n    ),\n    # API Key + priority setting\n    (\n        \"pover://{}@{}?priority=high\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + priority setting + html mode\n    (\n        \"pover://{}@{}?priority=high&format=html\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + priority setting + markdown mode\n    (\n        \"pover://{}@{}?priority=high&format=markdown\".format(\n            \"u\" * 30, \"a\" * 30\n        ),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + invalid priority setting\n    (\n        \"pover://{}@{}?priority=invalid\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + emergency(2) priority setting\n    (\n        \"pover://{}@{}?priority=emergency\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + emergency(2) priority setting (via numeric value\n    (\n        \"pover://{}@{}?priority=2\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + emergency priority setting with retry and expire\n    (\n        \"pover://{}@{}?priority=emergency&{}&{}\".format(\n            \"u\" * 30, \"a\" * 30, \"retry=30\", \"expire=300\"\n        ),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + emergency priority setting with text retry\n    (\n        \"pover://{}@{}?priority=emergency&{}&{}\".format(\n            \"u\" * 30, \"a\" * 30, \"retry=invalid\", \"expire=300\"\n        ),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + emergency priority setting with text expire\n    (\n        \"pover://{}@{}?priority=emergency&{}&{}\".format(\n            \"u\" * 30, \"a\" * 30, \"retry=30\", \"expire=invalid\"\n        ),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    # API Key + emergency priority setting with invalid expire\n    (\n        \"pover://{}@{}?priority=emergency&{}\".format(\n            \"u\" * 30, \"a\" * 30, \"expire=100000\"\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # API Key + emergency priority setting with invalid retry\n    (\n        \"pover://{}@{}?priority=emergency&{}\".format(\n            \"u\" * 30, \"a\" * 30, \"retry=15\"\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # API Key + priority setting (empty)\n    (\n        \"pover://{}@{}?priority=\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n        },\n    ),\n    (\n        \"pover://{}@{}\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"pover://{}@{}\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pover://{}@{}\".format(\"u\" * 30, \"a\" * 30),\n        {\n            \"instance\": NotifyPushover,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushover_urls():\n    \"\"\"NotifyPushover() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pushover_attachments(mock_post, tmpdir):\n    \"\"\"NotifyPushover() Attachment Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    user_key = \"u\" * 30\n    api_token = \"a\" * 30\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.content = dumps(\n        {\"status\": 1, \"request\": \"647d2300-702c-4b38-8b2f-d56326ae460b\"}\n    )\n    response.status_code = requests.codes.ok\n\n    # Prepare a bad response\n    bad_response = mock.Mock()\n    bad_response.content = dumps(\n        {\"status\": 1, \"request\": \"647d2300-702c-4b38-8b2f-d56326ae460b\"}\n    )\n    bad_response.status_code = requests.codes.internal_server_error\n\n    # Assign our good response\n    mock_post.return_value = response\n\n    # prepare our attachment\n    attach = apprise.AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    )\n\n    # Instantiate our object\n    obj = apprise.Apprise.instantiate(f\"pover://{user_key}@{api_token}/\")\n    assert isinstance(obj, NotifyPushover)\n\n    # Test our attachment\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Test our call count\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.pushover.net/1/messages.json\"\n    )\n\n    # Reset our mock object for multiple tests\n    mock_post.reset_mock()\n\n    # Test multiple attachments\n    assert attach.add(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Test our call count\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.pushover.net/1/messages.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.pushover.net/1/messages.json\"\n    )\n\n    # Reset our mock object for multiple tests\n    mock_post.reset_mock()\n\n    image = tmpdir.mkdir(\"pover_image\").join(\"test.jpg\")\n    image.write(\"a\" * NotifyPushover.attach_max_size_bytes)\n\n    attach = apprise.AppriseAttachment.instantiate(str(image))\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Test our call count\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.pushover.net/1/messages.json\"\n    )\n\n    # Reset our mock object for multiple tests\n    mock_post.reset_mock()\n\n    # Add 1 more byte to the file (putting it over the limit)\n    image.write(\"a\" * (NotifyPushover.attach_max_size_bytes + 1))\n\n    attach = apprise.AppriseAttachment.instantiate(str(image))\n    assert obj.notify(body=\"test\", attach=attach) is False\n\n    # Test our call count\n    assert mock_post.call_count == 0\n\n    # Test case when file is missing\n    attach = apprise.AppriseAttachment.instantiate(\n        f\"file://{image!s}?cache=False\"\n    )\n    os.unlink(str(image))\n    assert obj.notify(body=\"body\", title=\"title\", attach=attach) is False\n\n    # Test our call count\n    assert mock_post.call_count == 0\n\n    # Test unsuported files:\n    image = tmpdir.mkdir(\"pover_unsupported\").join(\"test.doc\")\n    image.write(\"a\" * 256)\n    attach = apprise.AppriseAttachment.instantiate(str(image))\n\n    # Content is silently ignored\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # prepare our attachment\n    attach = apprise.AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    )\n\n    # Throw an exception on the first call to requests.post()\n    for side_effect in (requests.RequestException(), OSError(), bad_response):\n        mock_post.side_effect = [side_effect, side_effect]\n\n        # We'll fail now because of our error handling\n        assert obj.send(body=\"test\", attach=attach) is False\n\n        # Same case without an attachment\n        assert obj.send(body=\"test\") is False\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pushover_edge_cases(mock_post):\n    \"\"\"NotifyPushover() Edge Cases.\"\"\"\n\n    # No token\n    with pytest.raises(TypeError):\n        NotifyPushover(token=None)\n\n    # Initialize some generic (but valid) tokens\n    token = \"a\" * 30\n    user_key = \"u\" * 30\n\n    invalid_device = \"d\" * 35\n\n    # Support strings\n    devices = f\"device1,device2,,,,{invalid_device}\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # No webhook id specified\n    with pytest.raises(TypeError):\n        NotifyPushover(user_key=user_key, webhook_id=None)\n\n    obj = NotifyPushover(user_key=user_key, token=token, targets=devices)\n    assert isinstance(obj, NotifyPushover)\n    # Our invalid device is ignored\n    assert len(obj.targets) == 2\n\n    # We notify the 2 devices loaded\n    assert (\n        obj.notify(\n            body=\"body\", title=\"title\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = NotifyPushover(user_key=user_key, token=token)\n    assert isinstance(obj, NotifyPushover)\n    # Default is to send to all devices, so there will be a\n    # device defined here\n    assert len(obj.targets) == 1\n\n    # This call succeeds because all of the devices are valid\n    assert (\n        obj.notify(\n            body=\"body\", title=\"title\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = NotifyPushover(user_key=user_key, token=token, targets=set())\n    assert isinstance(obj, NotifyPushover)\n    # Default is to send to all devices, so there will be a\n    # device defined here\n    assert len(obj.targets) == 1\n\n    # No User Key specified\n    with pytest.raises(TypeError):\n        NotifyPushover(user_key=None, token=\"abcd\")\n\n    # No Access Token specified\n    with pytest.raises(TypeError):\n        NotifyPushover(user_key=\"abcd\", token=None)\n\n    with pytest.raises(TypeError):\n        NotifyPushover(user_key=\"abcd\", token=\"  \")\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pushover_config_files(mock_post):\n    \"\"\"NotifyPushover() Config File Cases.\"\"\"\n    content = \"\"\"\n    urls:\n      - pover://USER@TOKEN:\n          - priority: -2\n            tag: pushover_int low\n          - priority: \"-2\"\n            tag: pushover_str_int low\n          - priority: low\n            tag: pushover_str low\n\n          # This will take on normal (default) priority\n          - priority: invalid\n            tag: pushover_invalid\n\n      - pover://USER2@TOKEN2:\n          - priority: 2\n            tag: pushover_int emerg\n          - priority: \"2\"\n            tag: pushover_str_int emerg\n          - priority: emergency\n            tag: pushover_str emerg\n    \"\"\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Create ourselves a config object\n    ac = apprise.AppriseConfig()\n    assert ac.add_config(content=content) is True\n\n    aobj = apprise.Apprise()\n\n    # Add our configuration\n    aobj.add(ac)\n\n    # We should be able to read our 7 servers from that\n    # 3x low\n    # 3x emerg\n    # 1x invalid (so takes on normal priority)\n    assert len(ac.servers()) == 7\n    assert len(aobj) == 7\n    assert len(list(aobj.find(tag=\"low\"))) == 3\n    for s in aobj.find(tag=\"low\"):\n        assert s.priority == PushoverPriority.LOW\n\n    assert len(list(aobj.find(tag=\"emerg\"))) == 3\n    for s in aobj.find(tag=\"emerg\"):\n        assert s.priority == PushoverPriority.EMERGENCY\n\n    assert len(list(aobj.find(tag=\"pushover_str\"))) == 2\n    assert len(list(aobj.find(tag=\"pushover_str_int\"))) == 2\n    assert len(list(aobj.find(tag=\"pushover_int\"))) == 2\n\n    assert len(list(aobj.find(tag=\"pushover_invalid\"))) == 1\n    assert (\n        next(aobj.find(tag=\"pushover_invalid\")).priority\n        == PushoverPriority.NORMAL\n    )\n\n    # Notifications work\n    # We test 'pushover_str_int' and 'low' which only matches 1 end point\n    assert (\n        aobj.notify(\n            title=\"title\", body=\"body\", tag=[(\"pushover_str_int\", \"low\")]\n        )\n        is True\n    )\n\n    # Notify everything loaded\n    assert aobj.notify(title=\"title\", body=\"body\") is True\n"
  },
  {
    "path": "tests/test_plugin_pushplus.py",
    "content": "#\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.pushplus import NotifyPushplus\n\nlogging.disable(logging.CRITICAL)\n\napprise_url_tests = (\n    (\n        \"pushplus://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pushplus://invalid!\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pushplus://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyPushplus,\n            \"privacy_url\": \"pushplus://****/\",\n        },\n    ),\n    (\n        \"pushplus://?token=abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyPushplus,\n            \"privacy_url\": \"pushplus://****/\",\n        },\n    ),\n    (\n        \"https://www.pushplus.plus/send?token=abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyPushplus,\n        },\n    ),\n    (\n        \"pushplus://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyPushplus,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"pushplus://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyPushplus,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"pushplus://ffffffffffffffffffffffffffffffff\",\n        {\n            \"instance\": NotifyPushplus,\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushplus_urls():\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_pushsafer.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import AppriseAttachment, NotifyType\nfrom apprise.plugins.pushsafer import NotifyPushSafer\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"psafer://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"psafer://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"psafers://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"psafer://{}\".format(\"a\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # This will fail because we're also expecting a server\n            # acknowledgement\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"psafer://{}\".format(\"b\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # invalid JSON response\n            \"requests_response_text\": \"{\",\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"psafer://{}\".format(\"c\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A failure has status set to zero\n            # We also expect an 'error' flag to be set\n            \"requests_response_text\": {\"status\": 0, \"error\": \"we failed\"},\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"psafers://{}\".format(\"d\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A failure has status set to zero\n            # Test without an 'error' flag\n            \"requests_response_text\": {\n                \"status\": 0,\n            },\n            \"notify_response\": False,\n        },\n    ),\n    # This will notify all users ('a')\n    (\n        \"psafer://{}\".format(\"e\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A status of 1 is a success\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n        },\n    ),\n    # This will notify a selected set of devices\n    (\n        \"psafer://{}/12/24/53\".format(\"e\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A status of 1 is a success\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n        },\n    ),\n    # Same as above, but exercises the to= argument\n    (\n        \"psafer://{}?to=12,24,53\".format(\"e\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A status of 1 is a success\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n        },\n    ),\n    # Set priority\n    (\n        \"psafer://{}?priority=emergency\".format(\"f\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n        },\n    ),\n    # Support integer value too\n    (\n        \"psafer://{}?priority=-1\".format(\"f\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n            \"force_debug\": True,\n        },\n    ),\n    # Invalid priority\n    (\n        \"psafer://{}?priority=invalid\".format(\"f\" * 20),\n        {\n            # Invalid Priority\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid priority\n    (\n        \"psafer://{}?priority=25\".format(\"f\" * 20),\n        {\n            # Invalid Priority\n            \"instance\": TypeError,\n        },\n    ),\n    # Set sound\n    (\n        \"psafer://{}?sound=ok\".format(\"g\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n        },\n    ),\n    # Support integer value too\n    (\n        \"psafers://{}?sound=14\".format(\"g\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n            \"privacy_url\": \"psafers://g...g\",\n        },\n    ),\n    # Invalid sound\n    (\n        \"psafer://{}?sound=invalid\".format(\"h\" * 20),\n        {\n            # Invalid Sound\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"psafer://{}?sound=94000\".format(\"h\" * 20),\n        {\n            # Invalid Sound\n            \"instance\": TypeError,\n        },\n    ),\n    # Set vibration (integer only)\n    (\n        \"psafers://{}?vibration=1\".format(\"h\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            \"requests_response_text\": {\n                \"status\": 1,\n            },\n            \"privacy_url\": \"psafers://h...h\",\n        },\n    ),\n    # Invalid sound\n    (\n        \"psafer://{}?vibration=invalid\".format(\"h\" * 20),\n        {\n            # Invalid Vibration\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid vibration\n    (\n        \"psafer://{}?vibration=25000\".format(\"h\" * 20),\n        {\n            # Invalid Vibration\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"psafers://{}\".format(\"d\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A failure has status set to zero\n            # Test without an 'error' flag\n            \"requests_response_text\": {\n                \"status\": 0,\n            },\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"psafer://{}\".format(\"d\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A failure has status set to zero\n            # Test without an 'error' flag\n            \"requests_response_text\": {\n                \"status\": 0,\n            },\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"psafers://{}\".format(\"d\" * 20),\n        {\n            \"instance\": NotifyPushSafer,\n            # A failure has status set to zero\n            # Test without an 'error' flag\n            \"requests_response_text\": {\n                \"status\": 0,\n            },\n            # Throws a series of connection and transfer exceptions when this\n            # flag is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushsafer_urls():\n    \"\"\"NotifyPushSafer() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_pushsafer_general(mock_post):\n    \"\"\"NotifyPushSafer() General Tests.\"\"\"\n\n    # Private Key\n    privatekey = \"abc123\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = dumps({\n        \"status\": 1,\n        \"success\": \"okay\",\n    })\n\n    # Exception should be thrown about the fact no private key was specified\n    with pytest.raises(TypeError):\n        NotifyPushSafer(privatekey=None)\n\n    # Multiple Attachment Support\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment()\n    for _ in range(0, 4):\n        attach.add(path)\n\n    obj = NotifyPushSafer(privatekey=privatekey)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test error reading attachment from disk\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n\n    # Test unsupported mime type\n    attach = AppriseAttachment(path)\n\n    attach[0]._mimetype = \"application/octet-stream\"\n\n    # We gracefully just don't send the attachment in these cases;\n    # The notify itself will still be successful\n    mock_post.reset_mock()\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # the 'p', 'p2', and 'p3' are the data variables used when including an\n    # image.\n    assert \"data\" in mock_post.call_args[1]\n    assert \"p\" not in mock_post.call_args[1][\"data\"]\n    assert \"p2\" not in mock_post.call_args[1][\"data\"]\n    assert \"p3\" not in mock_post.call_args[1][\"data\"]\n\n    # Invalid file path\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n"
  },
  {
    "path": "tests/test_plugin_pushy.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.pushy import NotifyPushy\n\nlogging.disable(logging.CRITICAL)\n\n\n# However we'll be okay if we return a proper response\nGOOD_RESPONSE = {\n    \"success\": True,\n}\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"pushy://\",\n        {\n            # No no secret api key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pushy://:@/\",\n        {\n            # just invalid all around\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"pushy://apikey\",\n        {\n            # No Device/Topic specified\n            \"instance\": NotifyPushy,\n            # Expected notify() response False (because we won't be able\n            # to actually notify anything if no device_key was specified\n            \"notify_response\": False,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://apikey/topic\",\n        {\n            # No Device/Topic specified\n            \"instance\": NotifyPushy,\n            # Expected notify() response False because the success flag\n            # was set to false\n            \"notify_response\": False,\n            \"requests_response_text\": {\"success\": False},\n        },\n    ),\n    (\n        \"pushy://apikey/topic\",\n        {\n            # No Device/Topic specified\n            \"instance\": NotifyPushy,\n            # Expected notify() response False because the success flag\n            # was set to false\n            \"notify_response\": False,\n            # Invalid JSON data\n            \"requests_response_text\": \"}\",\n        },\n    ),\n    (\n        \"pushy://apikey/%20(\",\n        {\n            # Invalid topic specified\n            \"instance\": NotifyPushy,\n            # Expected notify() response False because there is no one to\n            # notify\n            \"notify_response\": False,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://apikey/@device\",\n        {\n            # Everything is okay\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushy://a...y/@device/\",\n        },\n    ),\n    (\n        \"pushy://apikey/topic\",\n        {\n            # Everything is okay; no prefix means it's a topic\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushy://a...y/#topic/\",\n        },\n    ),\n    (\n        \"pushy://apikey/device/?sound=alarm.aiff\",\n        {\n            # alarm.aiff sound loaded\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://apikey/device/?badge=100\",\n        {\n            # set badge\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://apikey/device/?badge=invalid\",\n        {\n            # set invalid badge\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://apikey/device/?badge=-12\",\n        {\n            # set invalid badge\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://_/@device/#topic?key=apikey\",\n        {\n            # set device and topic\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://apikey/?to=@device\",\n        {\n            # test use of to= argument\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"pushy://_/@device/#topic?key=apikey\",\n        {\n            \"instance\": NotifyPushy,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            \"requests_response_text\": GOOD_RESPONSE,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"pushy://a...y/#topic/@device/\",\n        },\n    ),\n    (\n        \"pushy://_/@device/#topic?key=apikey\",\n        {\n            \"instance\": NotifyPushy,\n            \"requests_response_text\": GOOD_RESPONSE,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_pushy_urls():\n    \"\"\"NotifyPushy() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_qq.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.qq import NotifyQQ\n\nlogging.disable(logging.CRITICAL)\n\napprise_url_tests = (\n    (\n        \"qq://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"qq://invalid!\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"qq://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyQQ,\n            \"privacy_url\": \"qq://****/\",\n        },\n    ),\n    (\n        \"qq://?token=abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyQQ,\n            \"privacy_url\": \"qq://****/\",\n        },\n    ),\n    (\n        \"https://qmsg.zendee.cn/send/abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyQQ,\n        },\n    ),\n    (\n        \"qq://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyQQ,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"qq://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifyQQ,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"qq://ffffffffffffffffffffffffffffffff\",\n        {\n            \"instance\": NotifyQQ,\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_qq_urls():\n\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_reddit.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timedelta, timezone\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.reddit import NotifyReddit\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"reddit://\",\n        {\n            # Missing all credentials\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"reddit://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"reddit://user@app_id/app_secret/\",\n        {\n            # No password\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"reddit://user:password@app_id/\",\n        {\n            # No app secret\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"reddit://user:password@app%id/appsecret/apprise\",\n        {\n            # No invalid app_id (has percent)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"reddit://user:password@app%id/app_secret/apprise\",\n        {\n            # No invalid app_secret (has percent)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret/apprise?kind=invalid\",\n        {\n            # An Invalid Kind\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret/apprise\",\n        {\n            # Login failed\n            \"instance\": NotifyReddit,\n            # Expected notify() response is False because internally we would\n            # have failed to login\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret\",\n        {\n            # Login successful, but there was no subreddit to notify\n            \"instance\": NotifyReddit,\n            \"requests_response_text\": {\n                \"access_token\": \"abc123\",\n                \"token_type\": \"bearer\",\n                \"expires_in\": 100000,\n                \"scope\": \"*\",\n                \"refresh_token\": \"def456\",\n                # The below is used in the response:\n                \"json\": {\n                    # No errors during post\n                    \"errors\": [],\n                },\n            },\n            # Expected notify() response is False\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret/apprise\",\n        {\n            \"instance\": NotifyReddit,\n            \"requests_response_text\": {\n                \"access_token\": \"abc123\",\n                \"token_type\": \"bearer\",\n                \"expires_in\": 100000,\n                \"scope\": \"*\",\n                \"refresh_token\": \"def456\",\n                # The below is used in the response:\n                \"json\": {\n                    # Identify an error\n                    \"errors\": [\n                        (\"KEY\", \"DESC\", \"INFO\"),\n                    ],\n                },\n            },\n            # Expected notify() response is False because the\n            # reddit server provided us errors\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret/apprise\",\n        {\n            \"instance\": NotifyReddit,\n            \"requests_response_text\": {\n                \"access_token\": \"abc123\",\n                \"token_type\": \"bearer\",\n                # Test case where 'expires_in' entry is missing\n                \"scope\": \"*\",\n                \"refresh_token\": \"def456\",\n                # The below is used in the response:\n                \"json\": {\n                    # No errors during post\n                    \"errors\": [],\n                },\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"reddit://user:****@****/****/apprise\",\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret/apprise/subreddit2\",\n        {\n            # password:login acceptable\n            \"instance\": NotifyReddit,\n            \"requests_response_text\": {\n                \"access_token\": \"abc123\",\n                \"token_type\": \"bearer\",\n                \"expires_in\": 100000,\n                \"scope\": \"*\",\n                \"refresh_token\": \"def456\",\n                # The below is used in the response:\n                \"json\": {\n                    # No errors during post\n                    \"errors\": [],\n                },\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"reddit://user:****@****/****/apprise/subreddit2\",\n        },\n    ),\n    # Pass in some arguments to over-ride defaults\n    (\n        (\n            \"reddit://user:pass@id/secret/sub/\"\n            \"?ad=yes&nsfw=yes&replies=no&resubmit=yes&spoiler=yes&kind=self\"\n        ),\n        {\n            \"instance\": NotifyReddit,\n            \"requests_response_text\": {\n                \"access_token\": \"abc123\",\n                \"token_type\": \"bearer\",\n                \"expires_in\": 100000,\n                \"scope\": \"*\",\n                \"refresh_token\": \"def456\",\n                # The below is used in the response:\n                \"json\": {\n                    # No errors during post\n                    \"errors\": [],\n                },\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"reddit://user:****@****/****/sub\",\n        },\n    ),\n    # Pass in more arguments\n    (\n        (\n            \"reddit://\"\n            \"?user=l2g&pass=pass&app_secret=abc123&app_id=54321&to=sub1,sub2\"\n        ),\n        {\n            \"instance\": NotifyReddit,\n            \"requests_response_text\": {\n                \"access_token\": \"abc123\",\n                \"token_type\": \"bearer\",\n                \"expires_in\": 100000,\n                \"scope\": \"*\",\n                \"refresh_token\": \"def456\",\n                # The below is used in the response:\n                \"json\": {\n                    # No errors during post\n                    \"errors\": [],\n                },\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"reddit://l2g:****@****/****/sub1/sub2\",\n        },\n    ),\n    # More arguments ...\n    (\n        (\n            \"reddit://user:pass@id/secret/sub7/sub6/sub5/\"\n            \"?flair_id=wonder&flair_text=not%20for%20you\"\n        ),\n        {\n            \"instance\": NotifyReddit,\n            \"requests_response_text\": {\n                \"access_token\": \"abc123\",\n                \"token_type\": \"bearer\",\n                \"expires_in\": 100000,\n                \"scope\": \"*\",\n                \"refresh_token\": \"def456\",\n                # The below is used in the response:\n                \"json\": {\n                    # No errors during post\n                    \"errors\": [],\n                },\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"reddit://user:****@****/****/sub\",\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret/apprise\",\n        {\n            \"instance\": NotifyReddit,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"reddit://user:password@app-id/app-secret/apprise\",\n        {\n            \"instance\": NotifyReddit,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_reddit_urls():\n    \"\"\"NotifyReddit() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_reddit_general(mock_post):\n    \"\"\"NotifyReddit() General Tests.\"\"\"\n    NotifyReddit.clock_skew = timedelta(seconds=0)\n\n    # Generate a valid credentials:\n    kwargs = {\n        \"app_id\": \"a\" * 10,\n        \"app_secret\": \"b\" * 20,\n        \"user\": \"user\",\n        \"password\": \"pasword\",\n        \"targets\": \"apprise\",\n    }\n\n    # Epoch time:\n    epoch = datetime.fromtimestamp(0, timezone.utc)\n\n    good_response = mock.Mock()\n    good_response.content = dumps({\n        \"access_token\": \"abc123\",\n        \"token_type\": \"bearer\",\n        \"expires_in\": 100000,\n        \"scope\": \"*\",\n        \"refresh_token\": \"def456\",\n        # The below is used in the response:\n        \"json\": {\n            # No errors during post\n            \"errors\": [],\n        },\n    })\n    good_response.status_code = requests.codes.ok\n    good_response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n\n    # Prepare Mock\n    mock_post.return_value = good_response\n\n    # Variation Initializations\n    obj = NotifyReddit(**kwargs)\n    assert isinstance(obj, NotifyReddit)\n    assert isinstance(obj.url(), str)\n\n    # Dynamically pick up on a link\n    assert obj.send(body=\"http://hostname\") is True\n\n    bad_response = mock.Mock()\n    bad_response.content = \"\"\n    bad_response.status_code = 401\n\n    # Change our status code and try again\n    mock_post.return_value = bad_response\n    assert obj.send(body=\"test\") is False\n    assert obj.ratelimit_remaining == 1\n\n    # Return the status\n    mock_post.return_value = good_response\n\n    # Force a case where there are no more remaining posts allowed\n    good_response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 0,\n    }\n    # behind the scenes, it should cause us to update our rate limit\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n\n    # This should cause us to block\n    good_response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 10,\n    }\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 10\n\n    # Handle cases where we simply couldn't get this field\n    del good_response.headers[\"X-RateLimit-Remaining\"]\n    assert obj.send(body=\"test\") is True\n    # It remains set to the last value\n    assert obj.ratelimit_remaining == 10\n\n    # Reset our variable back to 1\n    good_response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n    # Handle cases where our epoch time is wrong\n    del good_response.headers[\"X-RateLimit-Reset\"]\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    good_response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds() + 1\n        ),\n        \"X-RateLimit-Remaining\": 0,\n    }\n\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    good_response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds() - 1\n        ),\n        \"X-RateLimit-Remaining\": 0,\n    }\n    assert obj.send(body=\"test\") is True\n\n    # Return our limits to always work\n    obj.ratelimit_remaining = 1\n\n    # Invalid JSON\n    response = mock.Mock()\n    response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n    response.content = \"{\"\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n    obj = NotifyReddit(**kwargs)\n    assert obj.send(body=\"test\") is False\n\n    # Return it to a parseable string but missing the entries we expect\n    response.content = \"{}\"\n    obj = NotifyReddit(**kwargs)\n    assert obj.send(body=\"test\") is False\n\n    # No access token provided\n    response.content = dumps({\n        \"access_token\": \"\",\n        \"json\": {\n            # No errors during post\n            \"errors\": [],\n        },\n    })\n    obj = NotifyReddit(**kwargs)\n    assert obj.send(body=\"test\") is False\n\n    # cause a json parsing issue now\n    response.content = None\n    obj = NotifyReddit(**kwargs)\n    assert obj.send(body=\"test\") is False\n\n    # Reset to what we consider a good response\n    good_response.content = dumps({\n        \"access_token\": \"abc123\",\n        \"token_type\": \"bearer\",\n        \"expires_in\": 100000,\n        \"scope\": \"*\",\n        \"refresh_token\": \"def456\",\n        # The below is used in the response:\n        \"json\": {\n            # No errors during post\n            \"errors\": [],\n        },\n    })\n    good_response.status_code = requests.codes.ok\n    good_response.headers = {\n        \"X-RateLimit-Reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"X-RateLimit-Remaining\": 1,\n    }\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Test sucessful re-authentication after failed post\n    mock_post.side_effect = [\n        good_response,\n        bad_response,\n        good_response,\n        good_response,\n    ]\n    obj = NotifyReddit(**kwargs)\n    assert obj.send(body=\"test\") is True\n    assert mock_post.call_count == 4\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://www.reddit.com/api/v1/access_token\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://oauth.reddit.com/api/submit\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://www.reddit.com/api/v1/access_token\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://oauth.reddit.com/api/submit\"\n    )\n\n    # Test failed re-authentication\n    mock_post.side_effect = [good_response, bad_response, bad_response]\n    obj = NotifyReddit(**kwargs)\n    assert obj.send(body=\"test\") is False\n\n    # Test exception handing on re-auth attempt\n    response.content = \"{\"\n    response.status_code = requests.codes.ok\n    mock_post.side_effect = [\n        good_response,\n        bad_response,\n        good_response,\n        response,\n    ]\n    obj = NotifyReddit(**kwargs)\n    assert obj.send(body=\"test\") is False\n"
  },
  {
    "path": "tests/test_plugin_resend.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.resend import NotifyResend\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# a resend api key\nUUID4 = \"re_FmABCDEF_987654321zqbabc123abc8fw\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"resend://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"resend://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"resend://abcd\",\n        {\n            # Just an broken email (no api key or email)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"resend://abcd@host\",\n        {\n            # Just an Email specified, no API Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"resend://invalid-api-key+*-d:user@example.com\",\n        {\n            # An invalid API Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"resend://abcd:user@example.com\",\n        {\n            # No To/Target Address(es) specified; so we sub in the same From\n            # address\n            \"instance\": NotifyResend,\n        },\n    ),\n    (\n        \"resend://abcd:user@example.com/newuser1@example.com\",\n        {\n            # A good email\n            \"instance\": NotifyResend,\n        },\n    ),\n    (\n        \"resend://abcd:user@example.com/newuser2@example.com?name=Jessica\",\n        {\n            # A good email\n            \"instance\": NotifyResend,\n            \"privacy_url\":\n                \"resend://a...d:user@example.com/newuser2@example.com\",\n            \"url_matches\": r\"name=Jessica\",\n        },\n    ),\n    (\n        (\n            \"resend://abcd@newuser4%40example.com?name=Ralph\"\n            \"&from=user2@example.ca\"\n        ),\n        {\n            # A good email\n            \"instance\": NotifyResend,\n            \"privacy_url\":\n                \"resend://a...d:user2@example.ca/\",\n            \"url_matches\": r\"name=Ralph\",\n            \"force_debug\": True,\n        },\n    ),\n    (\n        (\n            \"resend://?apikey=abcd&from=Joe<user@example.com>\"\n            \"&to=newuser5@example.com\"\n         ),\n        {\n            # A good email\n            \"instance\": NotifyResend,\n            \"privacy_url\":\n                \"resend://a...d:user@example.com/newuser5@example.com\",\n            \"url_matches\": r\"name=Joe\",\n        },\n    ),\n    (\n        (\n            \"resend://?apikey=abcd&from=Joe<user@example.com>\"\n            \"&reply=John<newuser6@example.com>\"\n         ),\n        {\n            # A good email\n            \"instance\": NotifyResend,\n            \"privacy_url\":\n                \"resend://a...d:user@example.com\",\n            \"url_matches\": r\"reply=John\",\n        },\n    ),\n    (\n        (\n            \"resend://?apikey=abcd&from=Joe<user@example.com>\"\n            \"&reply=garbage%\"\n         ),\n        {\n            # A good email but has a garbage reply-to value\n            \"instance\": NotifyResend,\n        },\n    ),\n    (\n        (\n            \"resend://abcd:user@example.com/newuser7@example.com\"\n            \"?bcc=l2g@nuxref.com\"\n        ),\n        {\n            # A good email with Blind Carbon Copy\n            \"instance\": NotifyResend,\n        },\n    ),\n    (\n        \"resend://abcd:user@example.com/newuser8@example.com?cc=l2g@nuxref.com\",\n        {\n            # A good email with Carbon Copy\n            \"instance\": NotifyResend,\n        },\n    ),\n    (\n        (\n            \"resend://abcd:user@example.com/newuser8@example.com?\"\n            \"cc=Chris<l2g@nuxref.com>\"\n        ),\n        {\n            # A good email with Carbon Copy + Name\n            \"instance\": NotifyResend,\n        },\n    ),\n\n    (\n        \"resend://abcd:user@example.com/newuser9@example.com?to=l2g@nuxref.com\",\n        {\n            # A good email with Carbon Copy\n            \"instance\": NotifyResend,\n        },\n    ),\n    (\n        \"resend://abcd:user@example.ca/newuser0@example.ca\",\n        {\n            \"instance\": NotifyResend,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"resend://abcd:user@example.uk/newuser01@example.uk\",\n        {\n            \"instance\": NotifyResend,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"resend://abcd:user@example.au/newuser02@example.au\",\n        {\n            \"instance\": NotifyResend,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_resend_urls():\n    \"\"\"NotifyResend() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_resend_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyResend() Edge Cases.\"\"\"\n\n    # no apikey\n    with pytest.raises(TypeError):\n        NotifyResend(apikey=None, from_addr=\"user@example.com\")\n\n    # invalid from email\n    with pytest.raises(TypeError):\n        NotifyResend(apikey=\"abcd\", from_addr=\"!invalid\")\n\n    # no email\n    with pytest.raises(TypeError):\n        NotifyResend(apikey=\"abcd\", from_addr=None)\n\n    # Invalid To email address\n    NotifyResend(\n        apikey=\"abcd\", from_addr=\"user@example.com\", targets=\"!invalid\"\n    )\n\n    # Test invalid bcc/cc entries mixed with good ones\n    assert isinstance(\n        NotifyResend(\n            apikey=\"abcd\",\n            from_addr=\"l2g@example.com\",\n            bcc=(\"abc@def.com\", \"!invalid\"),\n            cc=(\"abc@test.org\", \"!invalid\"),\n        ),\n        NotifyResend,\n    )\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_resend_attachments(mock_post, mock_get):\n    \"\"\"NotifyResend() Attachments.\"\"\"\n\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = request\n    mock_get.return_value = request\n\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    obj = Apprise.instantiate(\"resend://abcd:user@example.com\")\n    assert isinstance(obj, NotifyResend)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    mock_post.reset_mock()\n    mock_get.reset_mock()\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n"
  },
  {
    "path": "tests/test_plugin_revolt.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timedelta\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom random import choice\nfrom string import ascii_uppercase as str_alpha, digits as str_num\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, NotifyFormat, NotifyType\nfrom apprise.common import OverflowMode\nfrom apprise.plugins.revolt import NotifyRevolt\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Prepare a Valid Response\nREVOLT_GOOD_RESPONSE = dumps({\n    \"_id\": \"AAAPWPMMQA2JJB59BR2EASWWWW\",\n    \"nonce\": \"01HPWPPMDJABC2FTDG54CBKKKS\",\n    \"channel\": \"00000000000000000000000000\",\n    \"author\": \"011244Q9S8NCS67KMM9543W7JJ\",\n    \"content\": \"test\",\n})\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"revolt://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # An invalid url\n    (\n        \"revolt://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No channel_id specified\n    (\n        \"revolt://%s\" % (\"i\" * 24),\n        {\n            \"instance\": NotifyRevolt,\n            # Notify will fail\n            \"response\": False,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # channel_id specified on url, but no Bot Token\n    (\n        \"revolt://?channel=%s\" % (\"i\" * 24),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # channel_id specified on url\n    (\n        \"revolt://{}/?channel={}\".format(\"i\" * 24, \"i\" * 24),\n        {\n            \"instance\": NotifyRevolt,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"revolt://{}/?to={}\".format(\"i\" * 24, \"i\" * 24),\n        {\n            \"instance\": NotifyRevolt,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"revolt://{}/?to={}\".format(\"i\" * 24, \"i\" * 24),\n        {\n            \"instance\": NotifyRevolt,\n            # an invalid JSON Response\n            \"requests_response_text\": \"{\",\n        },\n    ),\n    # channel_id specified on url\n    (\n        \"revolt://{}/?channel={},%20\".format(\"i\" * 24, \"i\" * 24),\n        {\n            \"instance\": NotifyRevolt,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # Provide both a bot token and a channel id\n    (\n        \"revolt://{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"revolt://_?bot_token={}&channel={}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # different format support\n    (\n        \"revolt://{}/{}?format=markdown\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"revolt://{}/{}?format=text\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # Test with url\n    (\n        \"revolt://{}/{}?url=http://localhost\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # URL implies markdown unless explicitly set otherwise\n    (\n        \"revolt://{}/{}?format=text&url=http://localhost\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # Test with Icon URL\n    (\n        \"revolt://{}/{}?icon_url=http://localhost/test.jpg\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # Icon URL implies markdown unless explicitly set otherwise\n    (\n        \"revolt://{}/{}?format=text&icon_url=http://localhost/test.jpg\".format(\n            \"i\" * 24, \"t\" * 64\n        ),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    # Test without any image set\n    (\n        \"revolt://{}/{}\".format(\"i\" * 24, \"t\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            \"requests_response_code\": requests.codes.ok,\n            # don't include an image by default\n            \"include_image\": False,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"revolt://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"revolt://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"revolt://{}/{}/\".format(\"a\" * 24, \"b\" * 64),\n        {\n            \"instance\": NotifyRevolt,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n            # Our response expected server response\n            \"requests_response_text\": REVOLT_GOOD_RESPONSE,\n        },\n    ),\n)\n\n\ndef test_plugin_revolt_urls():\n    \"\"\"NotifyRevolt() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_revolt_notifications(mock_post):\n    \"\"\"NotifyRevolt() Notifications.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    bot_token = \"A\" * 24\n    channel_id = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = REVOLT_GOOD_RESPONSE\n\n    # Test our header parsing when not lead with a header\n    body = \"\"\"\n    # Heading\n    @everyone and @admin, wake and meet our new user <@123>; <@&456>\"\n    \"\"\"\n\n    results = NotifyRevolt.parse_url(\n        f\"revolt://{bot_token}/{channel_id}/?format=markdown\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"bot_token\"] == bot_token\n    assert results[\"targets\"] == [\n        channel_id,\n    ]\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == bot_token\n    assert results[\"fullpath\"] == f\"/{channel_id}/\"\n    assert results[\"path\"] == f\"/{channel_id}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"revolt\"\n    assert results[\"url\"] == f\"revolt://{bot_token}/{channel_id}/\"\n\n    instance = NotifyRevolt(**results)\n    assert isinstance(instance, NotifyRevolt)\n\n    response = instance.send(body=body)\n    assert response is True\n    assert mock_post.call_count == 1\n\n    # Reset our object\n    mock_post.reset_mock()\n\n    results = NotifyRevolt.parse_url(\n        f\"revolt://{bot_token}/{channel_id}/?format=text\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"bot_token\"] == bot_token\n    assert results[\"targets\"] == [\n        channel_id,\n    ]\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == bot_token\n    assert results[\"fullpath\"] == f\"/{channel_id}/\"\n    assert results[\"path\"] == f\"/{channel_id}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"revolt\"\n    assert results[\"url\"] == f\"revolt://{bot_token}/{channel_id}/\"\n\n    instance = NotifyRevolt(**results)\n    assert isinstance(instance, NotifyRevolt)\n\n    response = instance.send(body=body)\n    assert response is True\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\n@mock.patch(\"time.sleep\")\ndef test_plugin_revolt_general(mock_sleep, mock_post):\n    \"\"\"NotifyRevolt() General Checks.\"\"\"\n\n    # Prevent throttling\n    mock_sleep.return_value = True\n\n    # Turn off clock skew for local testing\n    NotifyRevolt.clock_skew = timedelta(seconds=0)\n\n    # Initialize some generic (but valid) tokens\n    bot_token = \"A\" * 24\n    channel_id = \",\".join([\"B\" * 32, \"C\" * 32]) + \", ,%%\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = REVOLT_GOOD_RESPONSE\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Remaining\": 0,\n        \"X-RateLimit-Reset-After\": 1,\n    }\n\n    # Invalid bot_token\n    with pytest.raises(TypeError):\n        NotifyRevolt(bot_token=None, targets=channel_id)\n    # Invalid bot_token (whitespace)\n    with pytest.raises(TypeError):\n        NotifyRevolt(bot_token=\"  \", targets=channel_id)\n\n    obj = NotifyRevolt(bot_token=bot_token, targets=channel_id)\n    assert obj.ratelimit_remaining == 1\n\n    # Test that we get a string response\n    assert isinstance(obj.url(), str)\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Force a case where there are no more remaining posts allowed\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Remaining\": 0,\n        \"X-RateLimit-Reset-After\": 0,\n    }\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # behind the scenes, it should cause us to update our rate limit\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n    assert isinstance(obj.ratelimit_reset, datetime)\n\n    # This should cause us to block\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Remaining\": 0,\n        \"X-RateLimit-Reset-After\": 3000,\n    }\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n    assert isinstance(obj.ratelimit_reset, datetime)\n\n    # Reset our variable back to 1\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Remaining\": 0,\n        \"X-RateLimit-Reset-After\": 10000,\n    }\n    del mock_post.return_value.headers[\"X-RateLimit-Remaining\"]\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n    assert isinstance(obj.ratelimit_reset, datetime)\n\n    # Return our object, but place it in the future forcing us to block\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Remaining\": 0,\n        \"X-RateLimit-Reset-After\": 0,\n    }\n\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Test 429 error response\n    mock_post.return_value.status_code = requests.codes.too_many_requests\n\n    # The below will attempt a second transmission and fail (because we didn't\n    # set up a second post request to pass) :)\n    assert obj.send(body=\"test\") is False\n\n    # Return our object, but place it in the future forcing us to block\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Remaining\": 0,\n        \"X-RateLimit-Reset-After\": 0,\n    }\n    assert obj.send(body=\"test\") is True\n\n    # Return our limits to always work\n    obj.ratelimit_remaining = 1\n\n    # Return our headers to normal\n    mock_post.return_value.headers = {\n        \"X-RateLimit-Remaining\": 0,\n        \"X-RateLimit-Reset-After\": 1,\n    }\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Create an apprise instance\n    a = Apprise()\n\n    # Our processing is slightly different when we aren't using markdown\n    # as we do not pre-parse content during our notifications\n    assert a.add(f\"revolt://{bot_token}/{channel_id}/?format=markdown\") is True\n\n    # Toggle our logo availability\n    a.asset.image_url_logo = None\n    assert (\n        a.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_revolt_overflow(mock_post):\n    \"\"\"NotifyRevolt() Overflow Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    bot_token = \"A\" * 24\n    channel_id = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = REVOLT_GOOD_RESPONSE\n\n    # Some variables we use to control the data we work with\n    body_len = 2005\n    title_len = 110\n\n    # Number of characters per line\n    row = 24\n\n    # Create a large body and title with random data\n    body = \"\".join(choice(str_alpha + str_num + \" \") for _ in range(body_len))\n    body = \"\\r\\n\".join([body[i : i + row] for i in range(0, len(body), row)])\n\n    # Create our title using random data\n    title = \"\".join(choice(str_alpha + str_num) for _ in range(title_len))\n\n    results = NotifyRevolt.parse_url(\n        f\"revolt://{bot_token}/{channel_id}/?overflow=split\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] is None\n    assert results[\"bot_token\"] == bot_token\n    assert results[\"targets\"] == [\n        channel_id,\n    ]\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == bot_token\n    assert results[\"fullpath\"] == f\"/{channel_id}/\"\n    assert results[\"path\"] == f\"/{channel_id}/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"revolt\"\n    assert results[\"url\"] == f\"revolt://{bot_token}/{channel_id}/\"\n\n    instance = NotifyRevolt(**results)\n    assert isinstance(instance, NotifyRevolt)\n\n    results = instance._apply_overflow(\n        body, title=title, overflow=OverflowMode.SPLIT\n    )\n    # Split into 2\n    assert len(results) == 2\n    assert len(results[0][\"title\"]) <= instance.title_maxlen\n    assert len(results[0][\"body\"]) <= instance.body_maxlen\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_revolt_markdown_extra(mock_post):\n    \"\"\"NotifyRevolt() Markdown Extra Checks.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    bot_token = \"A\" * 24\n    channel_id = \"B\" * 64\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = REVOLT_GOOD_RESPONSE\n\n    # Reset our apprise object\n    a = Apprise()\n\n    # We want to further test our markdown support to accommodate bug rased on\n    # 2022.10.25; see https://github.com/caronc/apprise/issues/717\n    assert a.add(f\"revolt://{bot_token}/{channel_id}/?format=markdown\") is True\n\n    test_markdown = \"[green-blue](https://google.com)\"\n\n    # This call includes an image with it's payload:\n    assert (\n        a.notify(\n            body=test_markdown,\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            body_format=NotifyFormat.TEXT,\n        )\n        is True\n    )\n\n    assert (\n        a.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n"
  },
  {
    "path": "tests/test_plugin_rocket_chat.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import NotifyType\nfrom apprise.plugins.rocketchat import NotifyRocketChat\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"rocket://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"rockets://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"rocket://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # No username or pass\n    (\n        \"rocket://localhost\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No room or channel\n    (\n        \"rocket://user:pass@localhost\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No valid rooms or channels\n    (\n        \"rocket://user:pass@localhost/#/!/@\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No user/pass combo\n    (\n        \"rocket://user@localhost/room/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No user/pass combo\n    (\n        \"rocket://localhost/room/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # A room and port identifier\n    (\n        \"rocket://user:pass@localhost:8080/room/\",\n        {\n            \"instance\": NotifyRocketChat,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": {\n                \"status\": \"success\",\n                \"data\": {\n                    \"authToken\": \"abcd\",\n                    \"userId\": \"user\",\n                },\n            },\n            \"privacy_url\": \"rocket://user:****@localhost\",\n        },\n    ),\n    # A channel (using the to=)\n    (\n        \"rockets://user:pass@localhost?to=#channel\",\n        {\n            \"instance\": NotifyRocketChat,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": {\n                \"status\": \"success\",\n                \"data\": {\n                    \"authToken\": \"abcd\",\n                    \"userId\": \"user\",\n                },\n            },\n            \"privacy_url\": \"rockets://user:****@localhost\",\n        },\n    ),\n    # A channel\n    (\n        \"rockets://user:pass@localhost/#channel\",\n        {\n            \"instance\": NotifyRocketChat,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": {\n                \"status\": \"success\",\n                \"data\": {\n                    \"authToken\": \"abcd\",\n                    \"userId\": \"user\",\n                },\n            },\n            \"privacy_url\": \"rockets://user:****@localhost\",\n        },\n    ),\n    # A channel using token based\n    (\n        \"rockets://user:token@localhost/#channel?mode=token\",\n        {\n            \"instance\": NotifyRocketChat,\n            \"privacy_url\": \"rockets://user:****@localhost\",\n        },\n    ),\n    # Token is detected based o it's length\n    (\n        \"rockets://user:{}@localhost/#channel\".format(\"t\" * 40),\n        {\n            \"instance\": NotifyRocketChat,\n            \"privacy_url\": \"rockets://user:****@localhost\",\n        },\n    ),\n    # Several channels\n    (\n        \"rocket://user:pass@localhost/#channel1/#channel2/?avatar=Yes\",\n        {\n            \"instance\": NotifyRocketChat,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": {\n                \"status\": \"success\",\n                \"data\": {\n                    \"authToken\": \"abcd\",\n                    \"userId\": \"user\",\n                },\n            },\n            \"privacy_url\": \"rocket://user:****@localhost\",\n        },\n    ),\n    # Several Rooms\n    (\n        \"rocket://user:pass@localhost/room1/room2\",\n        {\n            \"instance\": NotifyRocketChat,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": {\n                \"status\": \"success\",\n                \"data\": {\n                    \"authToken\": \"abcd\",\n                    \"userId\": \"user\",\n                },\n            },\n            \"privacy_url\": \"rocket://user:****@localhost\",\n        },\n    ),\n    # A room and channel\n    (\n        \"rocket://user:pass@localhost/room/#channel?mode=basic&avatar=Yes\",\n        {\n            \"instance\": NotifyRocketChat,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": {\n                \"status\": \"success\",\n                \"data\": {\n                    \"authToken\": \"abcd\",\n                    \"userId\": \"user\",\n                },\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"rocket://user:****@localhost\",\n        },\n    ),\n    # A user/pass where the pass matches a webtoken\n    # to ensure we get the right mode, we enforce basic mode\n    # so that web/token gets interpreted as a password\n    (\n        \"rockets://user:pass%2Fwithslash@localhost/#channel/?mode=basic\",\n        {\n            \"instance\": NotifyRocketChat,\n            # The response text is expected to be the following on a success\n            \"requests_response_text\": {\n                \"status\": \"success\",\n                \"data\": {\n                    \"authToken\": \"abcd\",\n                    \"userId\": \"user\",\n                },\n            },\n            \"privacy_url\": \"rockets://user:****@localhost\",\n        },\n    ),\n    # A room and channel\n    (\n        \"rockets://user:pass@localhost/rooma/#channela\",\n        {\n            # The response text is expected to be the following on a success\n            \"requests_response_code\": requests.codes.ok,\n            \"requests_response_text\": {\n                # return something other then a success message type\n                \"status\": \"failure\",\n            },\n            # Exception is thrown in this case\n            \"instance\": NotifyRocketChat,\n            # Notifications will fail in this event\n            \"response\": False,\n        },\n    ),\n    # A web token\n    (\n        \"rockets://web/token@localhost/@user/#channel/roomid\",\n        {\n            \"instance\": NotifyRocketChat,\n            \"privacy_url\": \"rockets://****@localhost/#channel/roomid\",\n        },\n    ),\n    (\n        \"rockets://user:web/token@localhost/@user/?mode=webhook\",\n        {\n            \"instance\": NotifyRocketChat,\n            \"privacy_url\": \"rockets://user:****@localhost\",\n        },\n    ),\n    (\n        \"rockets://user:web/token@localhost?to=@user2,#channel2\",\n        {\n            \"instance\": NotifyRocketChat,\n        },\n    ),\n    (\n        \"rockets://web/token@localhost/?avatar=No\",\n        {\n            # a simple webhook token with default values\n            \"instance\": NotifyRocketChat,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"rockets://****@localhost/\",\n        },\n    ),\n    (\n        \"rockets://localhost/@user/?mode=webhook&webhook=web/token\",\n        {\n            \"instance\": NotifyRocketChat,\n            \"privacy_url\": \"rockets://****@localhost/@user\",\n        },\n    ),\n    (\n        \"rockets://user:web/token@localhost/@user/?mode=invalid\",\n        {\n            # invalid mode\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"rocket://user:pass@localhost:8081/room1/room2\",\n        {\n            \"instance\": NotifyRocketChat,\n            # force a failure using basic mode\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"rockets://user:web/token@localhost?to=@user3,#channel3\",\n        {\n            \"instance\": NotifyRocketChat,\n            # force a failure using webhook mode\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"rocket://user:pass@localhost:8082/#channel\",\n        {\n            \"instance\": NotifyRocketChat,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"rocket://user:pass@localhost:8083/#chan1/#chan2/room\",\n        {\n            \"instance\": NotifyRocketChat,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_rocket_chat_urls():\n    \"\"\"NotifyRocketChat() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_rocket_chat_edge_cases(mock_post, mock_get):\n    \"\"\"NotifyRocketChat() Edge Cases.\"\"\"\n\n    # Chat ID\n    recipients = \"AbcD1245, @l2g, @lead2gold, #channel, #channel2\"\n\n    # Authentication\n    user = \"myuser\"\n    password = \"mypass\"\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = \"\"\n    mock_get.return_value.content = \"\"\n\n    obj = NotifyRocketChat(user=user, password=password, targets=recipients)\n    assert isinstance(obj, NotifyRocketChat) is True\n    assert len(obj.channels) == 2\n    assert len(obj.users) == 2\n    assert len(obj.rooms) == 1\n\n    # No Webhook specified\n    with pytest.raises(TypeError):\n        obj = NotifyRocketChat(webhook=None, mode=\"webhook\")\n\n    #\n    # Logout\n    #\n    assert obj.logout() is True\n\n    # Invalid JSON during Login\n    mock_post.return_value.content = \"{\"\n    mock_get.return_value.content = \"}\"\n    assert obj.login() is False\n\n    # Prepare Mock to fail\n    mock_post.return_value.content = \"\"\n    mock_get.return_value.content = \"\"\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    mock_get.return_value.status_code = requests.codes.internal_server_error\n\n    #\n    # Send Notification\n    #\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    assert obj._send(payload=\"test\", notify_type=NotifyType.INFO) is False\n\n    #\n    # Logout\n    #\n    assert obj.logout() is False\n\n    # KeyError handling\n    mock_post.return_value.status_code = 999\n    mock_get.return_value.status_code = 999\n\n    #\n    # Send Notification\n    #\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    assert obj._send(payload=\"test\", notify_type=NotifyType.INFO) is False\n\n    #\n    # Logout\n    #\n    assert obj.logout() is False\n\n    # Generate exceptions\n    mock_get.side_effect = requests.ConnectionError(\n        0, \"requests.ConnectionError() not handled\"\n    )\n    mock_post.side_effect = mock_get.side_effect\n\n    #\n    # Send Notification\n    #\n    assert obj._send(payload=\"test\", notify_type=NotifyType.INFO) is False\n\n    # Attempt the check again but fake a successful login\n    obj.login = mock.Mock()\n    obj.login.return_value = True\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    #\n    # Logout\n    #\n    assert obj.logout() is False\n"
  },
  {
    "path": "tests/test_plugin_rsyslog.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport socket\nfrom unittest import mock\n\nimport pytest\n\nimport apprise\n\nlogging.disable(logging.CRITICAL)\n\nfrom apprise.plugins.rsyslog import NotifyRSyslog  # noqa E402\n\n\n@mock.patch(\"socket.socket\")\n@mock.patch(\"os.getpid\")\ndef test_plugin_rsyslog_by_url(mock_getpid, mock_socket):\n    \"\"\"NotifyRSyslog() Apprise URLs.\"\"\"\n    payload = \"test\"\n    mock_connection = mock.Mock()\n\n    # Fix pid response since it can vary in length and this impacts the\n    # sendto() payload response\n    mock_getpid.return_value = 123\n\n    # our payload length\n    mock_connection.sendto.return_value = 16\n    mock_socket.return_value = mock_connection\n\n    # an invalid URL\n    assert NotifyRSyslog.parse_url(object) is None\n    assert NotifyRSyslog.parse_url(42) is None\n    assert NotifyRSyslog.parse_url(None) is None\n\n    # localhost does not lookup to any of the facility codes so this\n    # gets interpreted as a host\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost\") is True\n    assert r\"logpid=yes\" in obj.url()\n    assert obj.notify(body=payload) is True\n\n    mock_connection.sendto.return_value = 18\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost/?facility=local5\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost/local5\") is True\n    assert r\"logpid=yes\" in obj.url()\n    assert obj.notify(body=payload) is True\n\n    # Invalid instantiation\n    assert (\n        apprise.Apprise.instantiate(\"rsyslog://localhost/?facility=invalid\")\n        is None\n    )\n\n    mock_connection.sendto.return_value = 17\n    # j will cause a search to take place and match to daemon\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost/?facility=d\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost/daemon\") is True\n    assert r\"logpid=yes\" in obj.url()\n    assert obj.notify(body=payload) is True\n\n    # Test bad return count\n    mock_connection.sendto.return_value = 0\n    assert obj.notify(body=payload) is False\n\n    # Test with port\n    mock_connection.sendto.return_value = 17\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost:518\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost:518\") is True\n    assert r\"logpid=yes\" in obj.url()\n    assert obj.notify(body=payload) is True\n\n    # Set length to include title (for test)\n    mock_connection.sendto.return_value = 39\n    assert obj.notify(body=payload, title=\"Testing a title entry\") is True\n\n    # Return length back to where it was\n    mock_connection.sendto.return_value = 16\n\n    # Test with default port\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost:514\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost\") is True\n    assert r\"logpid=yes\" in obj.url()\n    assert obj.notify(body=payload) is True\n\n    # Specify a facility\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost/kern\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost/kern\") is True\n    assert r\"logpid=yes\" in obj.url()\n    assert obj.notify(body=payload) is True\n\n    # Specify a facility requiring a lookup and having the port identified\n    # resolves any ambiguity\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost:514/d\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost/daemon\") is True\n    assert r\"logpid=yes\" in obj.url()\n    mock_connection.sendto.return_value = 17  # daemon is one more byte in size\n    assert obj.notify(body=payload) is True\n\n    obj = apprise.Apprise.instantiate(\"rsyslog://localhost:9000/d?logpid=no\")\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost:9000/daemon\") is True\n    assert r\"logpid=no\" in obj.url()\n\n    # Verify our URL ID is generated\n    assert isinstance(obj.url_id(), str)\n\n    # Test notifications\n    # + 1 byte in size due to user\n    # + length of pid returned\n    mock_connection.sendto.return_value = (\n        len(payload) + 5 + len(str(mock_getpid.return_value))\n    )\n    assert obj.notify(body=payload) is True\n    # This only fails because the underlining sendto() will return a\n    # length different then what was expected\n    assert obj.notify(body=\"a different payload size\") is False\n\n    # Test timeouts and errors that can occur\n    mock_connection.sendto.return_value = None\n    mock_connection.sendto.side_effect = socket.gaierror\n    assert obj.notify(body=payload) is False\n\n    mock_connection.sendto.side_effect = socket.timeout\n    assert obj.notify(body=payload) is False\n\n\ndef test_plugin_rsyslog_edge_cases():\n    \"\"\"NotifyRSyslog() Edge Cases.\"\"\"\n\n    # Default\n    obj = NotifyRSyslog(host=\"localhost\", facility=None)\n    assert isinstance(obj, NotifyRSyslog)\n    assert obj.url().startswith(\"rsyslog://localhost/user\") is True\n    assert r\"logpid=yes\" in obj.url()\n\n    # Exception should be thrown about the fact no bot token was specified\n    with pytest.raises(TypeError):\n        NotifyRSyslog(host=\"localhost\", facility=\"invalid\")\n\n    with pytest.raises(TypeError):\n        NotifyRSyslog(host=\"localhost\", facility=object)\n"
  },
  {
    "path": "tests/test_plugin_ryver.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.ryver import NotifyRyver\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"ryver://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ryver://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ryver://apprise\",\n        {\n            # Just org provided (no token)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG?mode=invalid\",\n        {\n            # invalid mode provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ryver://x/ckhrjW8w672m6HG?mode=slack\",\n        {\n            # Invalid org\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG?mode=slack\",\n        {\n            # No username specified; this is still okay as we use whatever\n            # the user told the webhook to use; set our slack mode\n            \"instance\": NotifyRyver,\n        },\n    ),\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG?mode=ryver\",\n        {\n            # No username specified; this is still okay as we use whatever\n            # the user told the webhook to use; set our ryver mode\n            \"instance\": NotifyRyver,\n        },\n    ),\n    # Legacy webhook mode setting:\n    # Legacy webhook mode setting:\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG?webhook=slack\",\n        {\n            # No username specified; this is still okay as we use whatever\n            # the user told the webhook to use; set our slack mode\n            \"instance\": NotifyRyver,\n        },\n    ),\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG?webhook=ryver\",\n        {\n            # No username specified; this is still okay as we use whatever\n            # the user told the webhook to use; set our ryver mode\n            \"instance\": NotifyRyver,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ryver://apprise/c...G\",\n        },\n    ),\n    # Support Native URLs\n    (\n        \"https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyRyver,\n        },\n    ),\n    # Support Native URLs with arguments\n    (\n        (\n            \"https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG\"\n            \"?webhook=ryver\"\n        ),\n        {\n            \"instance\": NotifyRyver,\n        },\n    ),\n    (\n        \"ryver://caronc@apprise/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyRyver,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyRyver,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyRyver,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"ryver://apprise/ckhrjW8w672m6HG\",\n        {\n            \"instance\": NotifyRyver,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_ryver_urls():\n    \"\"\"NotifyRyver() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_ryver_edge_cases():\n    \"\"\"NotifyRyver() Edge Cases.\"\"\"\n\n    # No token\n    with pytest.raises(TypeError):\n        NotifyRyver(organization=\"abc\", token=None)\n\n    with pytest.raises(TypeError):\n        NotifyRyver(organization=\"abc\", token=\"  \")\n\n    # No organization\n    with pytest.raises(TypeError):\n        NotifyRyver(organization=None, token=\"abc\")\n\n    with pytest.raises(TypeError):\n        NotifyRyver(organization=\"  \", token=\"abc\")\n"
  },
  {
    "path": "tests/test_plugin_sendgrid.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.sendgrid import NotifySendGrid\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# a test UUID we can use\nUUID4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"sendgrid://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"sendgrid://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"sendgrid://abcd\",\n        {\n            # Just an broken email (no api key or email)\n            \"instance\": None,\n        },\n    ),\n    (\n        \"sendgrid://abcd@host\",\n        {\n            # Just an Email specified, no API Key\n            \"instance\": None,\n        },\n    ),\n    (\n        \"sendgrid://invalid-api-key+*-d:user@example.com\",\n        {\n            # An invalid API Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sendgrid://abcd:user@example.com\",\n        {\n            # No To/Target Address(es) specified; so we sub in the same From\n            # address\n            \"instance\": NotifySendGrid,\n        },\n    ),\n    (\n        \"sendgrid://abcd:user@example.com/newuser@example.com\",\n        {\n            # A good email\n            \"instance\": NotifySendGrid,\n        },\n    ),\n    (\n        \"sendgrid://abcd:user@example.com/bademailaddress\",\n        {\n            # won't be able to send email\n            \"instance\": NotifySendGrid,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        (\n            \"sendgrid://abcd:user@example.com/newuser@example.com\"\n            \"?bcc=l2g@nuxref.com\"\n        ),\n        {\n            # A good email with Blind Carbon Copy\n            \"instance\": NotifySendGrid,\n            \"force_debug\": True,\n        },\n    ),\n    (\n        (\n            \"sendgrid://abcd:user@example.com/newuser@example.com\"\n            \"?cc=l2g@nuxref.com\"\n        ),\n        {\n            # A good email with Carbon Copy\n            \"instance\": NotifySendGrid,\n        },\n    ),\n    (\n        (\n            \"sendgrid://abcd:user@example.com/newuser@example.com\"\n            \"?to=l2g@nuxref.com\"\n        ),\n        {\n            # A good email with Carbon Copy\n            \"instance\": NotifySendGrid,\n        },\n    ),\n    (\n        (\n            \"sendgrid://abcd:user@example.com/newuser@example.com\"\n            f\"?template={UUID4}\"\n        ),\n        {\n            # A good email with a template + no substitutions\n            \"instance\": NotifySendGrid,\n        },\n    ),\n    (\n        (\n            \"sendgrid://abcd:user@example.com/newuser@example.com\"\n            f\"?template={UUID4}&+sub=value&+sub2=value2\"\n        ),\n        {\n            # A good email with a template + substitutions\n            \"instance\": NotifySendGrid,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"sendgrid://a...d:user@example.com/\",\n        },\n    ),\n    (\n        \"sendgrid://abcd:user@example.ca/newuser@example.ca\",\n        {\n            \"instance\": NotifySendGrid,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"sendgrid://abcd:user@example.uk/newuser@example.uk\",\n        {\n            \"instance\": NotifySendGrid,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"sendgrid://abcd:user@example.au/newuser@example.au\",\n        {\n            \"instance\": NotifySendGrid,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_sendgrid_urls():\n    \"\"\"NotifySendGrid() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_sendgrid_edge_cases(mock_post, mock_get):\n    \"\"\"NotifySendGrid() Edge Cases.\"\"\"\n\n    # no apikey\n    with pytest.raises(TypeError):\n        NotifySendGrid(apikey=None, from_email=\"user@example.com\")\n\n    # invalid from email\n    with pytest.raises(TypeError):\n        NotifySendGrid(apikey=\"abcd\", from_email=\"!invalid\")\n\n    # no email\n    with pytest.raises(TypeError):\n        NotifySendGrid(apikey=\"abcd\", from_email=None)\n\n    # Invalid To email address\n    NotifySendGrid(\n        apikey=\"abcd\", from_email=\"user@example.com\", targets=\"!invalid\"\n    )\n\n    # Test invalid bcc/cc entries mixed with good ones\n    assert isinstance(\n        NotifySendGrid(\n            apikey=\"abcd\",\n            from_email=\"l2g@example.com\",\n            bcc=(\"abc@def.com\", \"!invalid\"),\n            cc=(\"abc@test.org\", \"!invalid\"),\n        ),\n        NotifySendGrid,\n    )\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_sendgrid_attachments(mock_post, mock_get):\n    \"\"\"NotifySendGrid() Attachments.\"\"\"\n\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = request\n    mock_get.return_value = request\n\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    obj = Apprise.instantiate(\"sendgrid://abcd:user@example.com\")\n    assert isinstance(obj, NotifySendGrid)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    mock_post.reset_mock()\n    mock_get.reset_mock()\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n"
  },
  {
    "path": "tests/test_plugin_sendpulse.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.sendpulse import NotifySendPulse\n\nlogging.disable(logging.CRITICAL)\n\nSENDPULSE_GOOD_RESPONSE = dumps({\n    \"access_token\": \"abc123\",\n    \"expires_in\": 3600,\n})\n\nSENDPULSE_BAD_RESPONSE = \"{\"\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\"sendpulse://\", {\n        \"instance\": TypeError,\n    }),\n    (\"sendpulse://:@/\", {\n        \"instance\": TypeError,\n    }),\n    (\"sendpulse://abcd\", {\n        # invalid from email\n        \"instance\": TypeError,\n    }),\n    (\"sendpulse://abcd@host.com\", {\n        # Just an Email specified, no client_id or client_secret\n        \"instance\": TypeError,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs/?template=invalid\", {\n        # Invalid template\n        \"instance\": TypeError,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs1/?template=123\", {\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs1/\", {\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs1/?format=text\", {\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs1/?format=html\", {\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://chris@example.com/client_id/cs1/?from=Chris\", {\n        # Set name only\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n        \"force_debug\": True,\n    }),\n    (\"sendpulse://?id=ci&secret=cs&user=chris@example.com\", {\n        # Set login through user= only\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://?id=ci&secret=cs&user=Chris<chris@example.com>\", {\n        # Set login through user= only\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://?id=ci&secret=cs&user=chris\", {\n        # Set login through user= only - invaild email\n        \"instance\": TypeError,\n    }),\n    (\"sendpulse://example.com/client_id/cs1/?user=chris\", {\n        # Set user as a name only\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://client_id/cs1/?user=chris@example.ca\", {\n        # Set user as email\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://client_id/cs1/?from=Chris<chris@example.com>\", {\n        # set full email with name\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://?from=Chris<chris@example.com>&id=ci&secret=cs\", {\n        # leverage all get params from URL\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs1a/\", {\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_BAD_RESPONSE,\n        # Notify will fail because auth failed\n        \"response\": False,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs2/\"\n     \"?bcc=l2g@nuxref.com\", {\n         # A good email with Blind Carbon Copy\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs2/\"\n     \"?bcc=invalid\", {\n         # A good email with Blind Carbon Copy\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs3/\"\n     \"?cc=l2g@nuxref.com\", {\n         # A good email with Carbon Copy\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs4/\"\n     \"?cc=Chris<l2g@nuxref.com>\", {\n         # A good email with Carbon Copy\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs5/\"\n     \"?cc=invalid\", {\n         # A good email with Carbon Copy\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs6/\"\n     \"?to=invalid\", {\n         # an invalid to email\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs7/chris@example.com\", {\n        # An email with a designated to email\n        \"instance\": NotifySendPulse,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs8/\"\n     \"?to=Chris<chris@example.com>\", {\n         # An email with a full name in in To field\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs9/\"\n     \"chris@example.com/chris2@example.com/Test<test@test.com>\", {\n         # Several emails to notify\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs10/\"\n     \"?cc=Chris<chris@example.com>\", {\n         # An email with a full name in cc\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs11/\"\n     \"?cc=chris@example.com\", {\n         # An email with a full name in cc\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs12/\"\n     \"?bcc=Chris<chris@example.com>\", {\n         # An email with a full name in bcc\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs13/\"\n     \"?bcc=chris@example.com\", {\n         # An email with a full name in bcc\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs14/\"\n     \"?to=Chris<chris@example.com>\", {\n         # An email with a full name in bcc\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs15/\"\n     \"?to=chris@example.com\", {\n         # An email with a full name in bcc\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n     }),\n    (\"sendpulse://user@example.com/client_id/cs16/\"\n     \"?template=1234&+sub=value&+sub2=value2\", {\n         # A good email with a template + substitutions\n         \"instance\": NotifySendPulse,\n         \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n\n         # Our expected url(privacy=True) startswith() response:\n         \"privacy_url\": \"sendpulse://user@example.com/c...d/c...6/\",\n     }),\n    (\"sendpulse://user@example.com/client_id/cs17/\", {\n        \"instance\": NotifySendPulse,\n        # force a failure\n        \"response\": False,\n        \"requests_response_code\": requests.codes.internal_server_error,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs18/\", {\n        \"instance\": NotifySendPulse,\n        # throw a bizarre code forcing us to fail to look it up\n        \"response\": False,\n        \"requests_response_code\": 999,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n    (\"sendpulse://user@example.com/client_id/cs19/\", {\n        \"instance\": NotifySendPulse,\n        # Throws a series of connection and transfer exceptions when this flag\n        # is set and tests that we gracefully handle them\n        \"test_requests_exceptions\": True,\n        \"requests_response_text\": SENDPULSE_GOOD_RESPONSE,\n    }),\n)\n\n\ndef test_plugin_sendpulse_urls():\n    \"\"\"\n    NotifySendPulse() Apprise URLs\n\n    \"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sendpulse_edge_cases(mock_post):\n    \"\"\"\n    NotifySendPulse() Edge Cases\n    \"\"\"\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n    request.content = SENDPULSE_GOOD_RESPONSE\n\n    # Prepare Mock\n    mock_post.return_value = request\n\n    obj = Apprise.instantiate(\n        \"sendpulse://user@example.com/ci/cs/Test<test@example.com>\")\n\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n\n    # Test our call count\n    assert mock_post.call_count == 2\n\n    # Authentication\n    assert mock_post.call_args_list[0][0][0] == \\\n        \"https://api.sendpulse.com/oauth/access_token\"\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload == {\n        \"grant_type\": \"client_credentials\",\n        \"client_id\": \"ci\",\n        \"client_secret\": \"cs\",\n    }\n\n    assert mock_post.call_args_list[1][0][0] == \\\n        \"https://api.sendpulse.com/smtp/emails\"\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    assert payload == {\n        \"email\": {\n            \"from\": {\n                \"email\": \"user@example.com\", \"name\": \"Apprise\"\n            },\n            \"to\": [{\"email\": \"test@example.com\", \"name\": \"Test\"}],\n            \"subject\": \"title\", \"text\": \"body\", \"html\": \"Ym9keQ==\"}}\n\n    mock_post.reset_mock()\n\n    obj = Apprise.instantiate(\"sendpulse://user@example.com/ci/cs/?from=John\")\n\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n\n    # Test our call count\n    assert mock_post.call_count == 2\n\n    # Authentication\n    assert mock_post.call_args_list[0][0][0] == \\\n        \"https://api.sendpulse.com/oauth/access_token\"\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert payload == {\n        \"grant_type\": \"client_credentials\",\n        \"client_id\": \"ci\",\n        \"client_secret\": \"cs\",\n    }\n\n    assert mock_post.call_args_list[1][0][0] == \\\n        \"https://api.sendpulse.com/smtp/emails\"\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    assert payload == {\n        \"email\": {\n            \"from\": {\n                \"email\": \"user@example.com\", \"name\": \"John\"\n            },\n            \"to\": [{\"email\": \"user@example.com\", \"name\": \"John\"}],\n            \"subject\": \"title\", \"text\": \"body\", \"html\": \"Ym9keQ==\"}}\n\n    mock_post.reset_mock()\n\n    # Second call no longer needs to authenticate\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n\n    assert mock_post.call_count == 1\n\n    assert mock_post.call_args_list[0][0][0] == \\\n        \"https://api.sendpulse.com/smtp/emails\"\n\n    # force an exception\n    mock_post.side_effect = requests.RequestException\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is False\n\n    # Set an invalid return code\n    mock_post.side_effect = None\n    request.status_code = 403\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is False\n\n    # Test re-authentication\n    mock_post.reset_mock()\n    request = mock.Mock()\n    obj = Apprise.instantiate(\"sendpulse://usr2@example.com/ci/cs/?from=Retry\")\n\n    class sendpulse:\n        def __init__(self):\n            # 200 login okay\n            # 401 on retrival\n            # recursive re-attempt to login returns 200\n            # fetch after works\n            self._side_effect = iter([\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.ok,\n            ])\n\n        @property\n        def status_code(self):\n            return next(self._side_effect)\n\n        @property\n        def content(self):\n            return SENDPULSE_GOOD_RESPONSE\n\n    mock_post.return_value = sendpulse()\n\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n\n    assert mock_post.call_count == 4\n    # Authentication\n    assert mock_post.call_args_list[0][0][0] == \\\n        \"https://api.sendpulse.com/oauth/access_token\"\n    # 401 received\n    assert mock_post.call_args_list[1][0][0] == \\\n        \"https://api.sendpulse.com/smtp/emails\"\n    # Re-authenticate\n    assert mock_post.call_args_list[2][0][0] == \\\n        \"https://api.sendpulse.com/oauth/access_token\"\n    # Try again\n    assert mock_post.call_args_list[3][0][0] == \\\n        \"https://api.sendpulse.com/smtp/emails\"\n\n    # Test re-authentication  (no recursive loops)\n    mock_post.reset_mock()\n    request = mock.Mock()\n    obj = Apprise.instantiate(\"sendpulse://usr2@example.com/ci/cs/?from=Retry\")\n\n    class sendpulse:\n        def __init__(self):\n            # oauth always returns okay but notify returns 401\n            # recursive re-attempt only once\n            self._side_effect = iter([\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.unauthorized,\n                requests.codes.ok, requests.codes.unauthorized,\n            ])\n\n        @property\n        def status_code(self):\n            return next(self._side_effect)\n\n        @property\n        def content(self):\n            return SENDPULSE_GOOD_RESPONSE\n\n    mock_post.return_value = sendpulse()\n\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is False\n\n    assert mock_post.call_count == 4\n    # Authentication\n    assert mock_post.call_args_list[0][0][0] == \\\n        \"https://api.sendpulse.com/oauth/access_token\"\n    # 401 received\n    assert mock_post.call_args_list[1][0][0] == \\\n        \"https://api.sendpulse.com/smtp/emails\"\n    # Re-authenticate\n    assert mock_post.call_args_list[2][0][0] == \\\n        \"https://api.sendpulse.com/oauth/access_token\"\n    # Last failed attempt\n    assert mock_post.call_args_list[3][0][0] == \\\n        \"https://api.sendpulse.com/smtp/emails\"\n\n    mock_post.side_effect = None\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n    request.content = SENDPULSE_GOOD_RESPONSE\n    mock_post.return_value = request\n    for expires_in in (None, -1, \"garbage\", 3600, 300000):\n        request.content = dumps({\n            \"access_token\": \"abc123\",\n            \"expires_in\": expires_in,\n        })\n\n        # Instantiate our object\n        obj = Apprise.instantiate(\"sendpulse://user@example.com/ci/cs/\")\n\n        # Test variations of responses\n        obj.notify(\n            body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n\n        # expires_in is missing\n        request.content = dumps({\n            \"access_token\": \"abc123\",\n        })\n\n        # Instantiate our object\n        obj = Apprise.instantiate(\"sendpulse://user@example.com/ci/cs/\")\n        assert obj.notify(\n            body=\"body\", title=\"title\", notify_type=NotifyType.INFO) is True\n\n\ndef test_plugin_sendpulse_fail_cases():\n    \"\"\"\n    NotifySendPulse() Fail Cases\n\n    \"\"\"\n\n    # no client_id\n    with pytest.raises(TypeError):\n        NotifySendPulse(\n            client_id=\"abcd\", client_secret=None,\n            from_addr=\"user@example.com\")\n\n    with pytest.raises(TypeError):\n        NotifySendPulse(\n            client_id=None, client_secret=\"abcd123\",\n            from_addr=\"user@example.com\")\n\n    # invalid from email\n    with pytest.raises(TypeError):\n        NotifySendPulse(\n            client_id=\"abcd\", client_secret=\"abcd456\", from_addr=\"!invalid\")\n\n    # no email\n    with pytest.raises(TypeError):\n        NotifySendPulse(\n            client_id=\"abcd\", client_secret=\"abcd789\", from_addr=None)\n\n    # Invalid To email address\n    NotifySendPulse(\n        client_id=\"abcd\", client_secret=\"abcd321\",\n        from_addr=\"user@example.com\", targets=\"!invalid\")\n\n    # Test invalid bcc/cc entries mixed with good ones\n    assert isinstance(NotifySendPulse(\n        client_id=\"abcd\", client_secret=\"abcd654\",\n        from_addr=\"l2g@example.com\",\n        bcc=(\"abc@def.com\", \"!invalid\"),\n        cc=(\"abc@test.org\", \"!invalid\")), NotifySendPulse)\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sendpulse_attachments(mock_post):\n    \"\"\"\n    NotifySendPulse() Attachments\n\n    \"\"\"\n\n    request = mock.Mock()\n    request.status_code = requests.codes.ok\n    request.content = SENDPULSE_GOOD_RESPONSE\n\n    # Prepare Mock\n    mock_post.return_value = request\n\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    obj = Apprise.instantiate(\"sendpulse://user@example.com/aaaa/bbbb\")\n    assert isinstance(obj, NotifySendPulse)\n    assert obj.notify(\n        body=\"body\", title=\"title\", notify_type=NotifyType.INFO,\n        attach=attach) is True\n\n    mock_post.reset_mock()\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"os.path.isfile\", return_value=False):\n        assert obj.notify(\n            body=\"body\", title=\"title\", notify_type=NotifyType.INFO,\n            attach=attach) is False\n\n    # Try again in a use case where we can't access the file\n    with mock.patch(\"builtins.open\", side_effect=OSError):\n        assert obj.notify(\n            body=\"body\", title=\"title\", notify_type=NotifyType.INFO,\n            attach=attach) is False\n"
  },
  {
    "path": "tests/test_plugin_serverchan.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.serverchan import NotifyServerChan\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"schan://\",\n        {\n            # No Access Token specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"schan://a_bd_/\",\n        {\n            # invalid Access Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"schan://12345678\",\n        {\n            # access token\n            \"instance\": NotifyServerChan,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"schan://1...8\",\n        },\n    ),\n    (\n        \"schan://{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyServerChan,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"schan://{}\".format(\"a\" * 8),\n        {\n            \"instance\": NotifyServerChan,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_serverchan_urls():\n    \"\"\"NotifyServerChan() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_ses.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport sys\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment\nfrom apprise.plugins.ses import NotifySES\n\nlogging.disable(logging.CRITICAL)\n\nif hasattr(sys, \"pypy_version_info\"):\n    raise pytest.skip(\n        reason=\"Skipping test cases which stall on PyPy\",\n        allow_module_level=True,\n    )\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\nAWS_SES_GOOD_RESPONSE = \"\"\"\n    <SendRawEmailResponse\n         xmlns=\"http://ses.amazonaws.com/doc/2010-12-01/\">\n      <SendRawEmailResult>\n        <MessageId>\n           010f017d87656ee2-a2ea291f-79ea-\n           44f3-9d25-00d041de3007-000000</MessageId>\n      </SendRawEmailResult>\n      <ResponseMetadata>\n        <RequestId>7abb454e-904b-4e46-a23c-2f4d2fc127a6</RequestId>\n      </ResponseMetadata>\n    </SendRawEmailResponse>\n    \"\"\"\n\nTEST_ACCESS_KEY_ID = \"AHIAJGNT76XIMXDBIJYA\"\nTEST_ACCESS_KEY_SECRET = \"bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9\"\nTEST_REGION = \"us-east-2\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"ses://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ses://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ses://user@example.com/T1JJ3T3L2\",\n        {\n            # Just Token 1 provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ses://user@example.com/T1JJ3TD4JD/TIiajkdnlazk7FQ/\",\n        {\n            # Missing a region\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ses://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2\",\n        {\n            # No email\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"ses://user@example.com/T1JJ3TD4JD/TIiajkdnlazk7FQ/user2@example.com\",\n        {\n            # Missing a region (but has email)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/\"\n            \"us-west-2?reply=invalid-email\"\n        ),\n        {\n            # An invalid reply-to address\n            \"instance\": TypeError,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/\"\n            \"us-west-2\"\n        ),\n        {\n            # we have a valid URL and we'll use our own email as a target\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3TD4JD/TIiajkdnlazk7FQ/us-west-2/\"\n            \"user2@example.ca/user3@example.eu\"\n        ),\n        {\n            # Multi Email Suppport\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"ses://user@example.com/T...D/****/us-west-2\",\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlaevi7FQ/us-east-1\"\n            \"?to=user2@example.ca\"\n        ),\n        {\n            # leveraging to: keyword\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n        },\n    ),\n    (\n        (\n            \"ses://?from=user@example.com&region=us-west-2&access=T1JJ3T3L2\"\n            \"&secret=A1BRTD4JD/TIiajkdnlaevi7FQ\"\n            \"&reply=No One <noreply@yahoo.ca>\"\n            \"&bcc=user.bcc@example.com,user2.bcc@example.com,invalid-email\"\n            \"&cc=user.cc@example.com,user2.cc@example.com,invalid-email\"\n            \"&to=user2@example.ca\"\n        ),\n        {\n            # leveraging a ton of our keywords\n            # We also test invlid emails specified on the bcc and cc list\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/\"\n            \"?name=From%20Name&to=user2@example.ca,invalid-email\"\n        ),\n        {\n            # leveraging a ton of our keywords\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/\"\n            \"?format=text\"\n        ),\n        {\n            # Send email as a text (instead of HTML)\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/\"\n            \"?to=invalid-email\"\n        ),\n        {\n            # An invalid email will get dropped during the initialization\n            # we'll have no targets to notify afterwards\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n            # As a result, we won't be able to notify anyone\n            \"notify_response\": False,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiacevi7FQ/us-west-2/\"\n            \"user2@example.com\"\n        ),\n        {\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        (\n            \"ses://user@example.com/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlavi7FQ/us-west-2/\"\n            \"user2@example.com\"\n        ),\n        {\n            \"instance\": NotifySES,\n            # Our response expected server response\n            \"requests_response_text\": AWS_SES_GOOD_RESPONSE,\n            # Throws a series of connection and transfer exceptions when this\n            # flag is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_ses_urls():\n    \"\"\"NotifySES() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n# We initialize a post object just incase a test fails below\n# we don't want it sending any notifications upstream\n@mock.patch(\"requests.post\")\ndef test_plugin_ses_edge_cases(mock_post):\n    \"\"\"NotifySES() Edge Cases.\"\"\"\n\n    # Initializes the plugin with a valid access, but invalid access key\n    with pytest.raises(TypeError):\n        # No access_key_id specified\n        NotifySES(\n            from_addr=\"user@example.eu\",\n            access_key_id=None,\n            secret_access_key=TEST_ACCESS_KEY_SECRET,\n            region_name=TEST_REGION,\n            targets=\"user@example.ca\",\n        )\n\n    with pytest.raises(TypeError):\n        # No secret_access_key specified\n        NotifySES(\n            from_addr=\"user@example.eu\",\n            access_key_id=TEST_ACCESS_KEY_ID,\n            secret_access_key=None,\n            region_name=TEST_REGION,\n            targets=\"user@example.ca\",\n        )\n\n    with pytest.raises(TypeError):\n        # No region_name specified\n        NotifySES(\n            from_addr=\"user@example.eu\",\n            access_key_id=TEST_ACCESS_KEY_ID,\n            secret_access_key=TEST_ACCESS_KEY_SECRET,\n            region_name=None,\n            targets=\"user@example.ca\",\n        )\n\n    # No recipients\n    obj = NotifySES(\n        from_addr=\"user@example.eu\",\n        access_key_id=TEST_ACCESS_KEY_ID,\n        secret_access_key=TEST_ACCESS_KEY_SECRET,\n        region_name=TEST_REGION,\n        targets=None,\n    )\n\n    # The object initializes properly but would not be able to send anything\n    assert obj.notify(body=\"test\", title=\"test\") is False\n\n    # The phone number is invalid, and without it, there is nothing\n    # to notify; we\n    obj = NotifySES(\n        from_addr=\"user@example.eu\",\n        access_key_id=TEST_ACCESS_KEY_ID,\n        secret_access_key=TEST_ACCESS_KEY_SECRET,\n        region_name=TEST_REGION,\n        targets=\"invalid-email\",\n    )\n\n    # The object initializes properly but would not be able to send anything\n    assert obj.notify(body=\"test\", title=\"test\") is False\n\n\ndef test_plugin_ses_url_parsing():\n    \"\"\"NotifySES() URL Parsing.\"\"\"\n\n    # No recipients\n    results = NotifySES.parse_url(\n        \"ses://{}/{}/{}/{}/\".format(\n            \"user@example.com\",\n            TEST_ACCESS_KEY_ID,\n            TEST_ACCESS_KEY_SECRET,\n            TEST_REGION,\n        )\n    )\n\n    # Confirm that there were no recipients found\n    assert len(results[\"targets\"]) == 0\n    assert \"region_name\" in results\n    assert results[\"region_name\"] == TEST_REGION\n    assert \"access_key_id\" in results\n    assert results[\"access_key_id\"] == TEST_ACCESS_KEY_ID\n    assert \"secret_access_key\" in results\n    assert results[\"secret_access_key\"] == TEST_ACCESS_KEY_SECRET\n\n    # Detect recipients\n    results = NotifySES.parse_url(\n        \"ses://{}/{}/{}/{}/{}/{}/\".format(\n            \"user@example.com\",\n            TEST_ACCESS_KEY_ID,\n            TEST_ACCESS_KEY_SECRET,\n            # Uppercase Region won't break anything\n            TEST_REGION.upper(),\n            \"user1@example.ca\",\n            \"user2@example.eu\",\n        )\n    )\n\n    # Confirm that our recipients were found\n    assert len(results[\"targets\"]) == 2\n    assert \"user1@example.ca\" in results[\"targets\"]\n    assert \"user2@example.eu\" in results[\"targets\"]\n    assert \"region_name\" in results\n    assert results[\"region_name\"] == TEST_REGION\n    assert \"access_key_id\" in results\n    assert results[\"access_key_id\"] == TEST_ACCESS_KEY_ID\n    assert \"secret_access_key\" in results\n    assert results[\"secret_access_key\"] == TEST_ACCESS_KEY_SECRET\n\n\ndef test_plugin_ses_aws_response_handling():\n    \"\"\"NotifySES() AWS Response Handling.\"\"\"\n    # Not a string\n    response = NotifySES.aws_response_to_dict(None)\n    assert response[\"type\"] is None\n    assert response[\"request_id\"] is None\n\n    # Invalid XML\n    response = NotifySES.aws_response_to_dict(\n        '<Bad Response xmlns=\"http://ses.amazonaws.com/doc/2010-03-31/\">'\n    )\n    assert response[\"type\"] is None\n    assert response[\"request_id\"] is None\n\n    # Single Element in XML\n    response = NotifySES.aws_response_to_dict(\n        \"<SingleElement></SingleElement>\"\n    )\n    assert response[\"type\"] == \"SingleElement\"\n    assert response[\"request_id\"] is None\n\n    # Empty String\n    response = NotifySES.aws_response_to_dict(\"\")\n    assert response[\"type\"] is None\n    assert response[\"request_id\"] is None\n\n    response = NotifySES.aws_response_to_dict(\"\"\"\n        <SendRawEmailResponse\n             xmlns=\"http://ses.amazonaws.com/doc/2010-12-01/\">\n          <SendRawEmailResult>\n            <MessageId>\n               010f017d87656ee2-a2ea291f-79ea-44f3-9d25-00d041de307</MessageId>\n          </SendRawEmailResult>\n          <ResponseMetadata>\n            <RequestId>7abb454e-904b-4e46-a23c-2f4d2fc127a6</RequestId>\n          </ResponseMetadata>\n        </SendRawEmailResponse>\n        \"\"\")\n    assert response[\"type\"] == \"SendRawEmailResponse\"\n    assert response[\"request_id\"] == \"7abb454e-904b-4e46-a23c-2f4d2fc127a6\"\n    assert (\n        response[\"message_id\"]\n        == \"010f017d87656ee2-a2ea291f-79ea-44f3-9d25-00d041de307\"\n    )\n\n    response = NotifySES.aws_response_to_dict(\"\"\"\n        <ErrorResponse xmlns=\"http://ses.amazonaws.com/doc/2010-03-31/\">\n            <Error>\n                <Type>Sender</Type>\n                <Code>InvalidParameter</Code>\n                <Message>Invalid parameter</Message>\n            </Error>\n            <RequestId>b5614883-babe-56ca-93b2-1c592ba6191e</RequestId>\n        </ErrorResponse>\n        \"\"\")\n    assert response[\"type\"] == \"ErrorResponse\"\n    assert response[\"request_id\"] == \"b5614883-babe-56ca-93b2-1c592ba6191e\"\n    assert response[\"error_type\"] == \"Sender\"\n    assert response[\"error_code\"] == \"InvalidParameter\"\n    assert response[\"error_message\"] == \"Invalid parameter\"\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_ses_attachments(mock_post):\n    \"\"\"NotifySES() Attachment Checks.\"\"\"\n\n    # Prepare Mock return object\n    response = mock.Mock()\n    response.content = AWS_SES_GOOD_RESPONSE\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n\n    # prepare our attachment\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Test our markdown\n    obj = Apprise.instantiate(\n        \"ses://{}/{}/{}/{}/\".format(\n            \"user@example.com\",\n            TEST_ACCESS_KEY_ID,\n            TEST_ACCESS_KEY_SECRET,\n            TEST_REGION,\n        )\n    )\n\n    # Send a good attachment\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # Add another attachment so we drop into the area of the PushBullet code\n    # that sends remaining attachments (if more detected)\n    attach.add(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our attachments\n    assert obj.notify(body=\"test\", attach=attach) is True\n\n    # Test our call count\n    assert mock_post.call_count == 1\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # An invalid attachment will cause a failure\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    attach = AppriseAttachment(path)\n    assert obj.notify(body=\"test\", attach=attach) is False\n"
  },
  {
    "path": "tests/test_plugin_seven.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.seven import NotifySeven\n\nlogging.disable(logging.CRITICAL)\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"seven://\",\n        {\n            # No hostname/apikey specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"seven://{}/15551232000\".format(\"a\" * 25),\n        {\n            # target phone number becomes who we text too; all is good\n            \"instance\": NotifySeven,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"seven://a...a/15551232000\",\n        },\n    ),\n    (\n        \"seven://{}/15551232000\".format(\"a\" * 25),\n        {\n            \"instance\": NotifySeven,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"seven://{}/15551232000\".format(\"a\" * 25),\n        {\n            \"instance\": NotifySeven,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"seven://{}/15551232000\".format(\"a\" * 25),\n        {\n            \"instance\": NotifySeven,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"seven://{}/?to=15551232000\".format(\"a\" * 25),\n        {\n            # target phone number using to=\n            \"instance\": NotifySeven,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"seven://a...a/15551232000\",\n        },\n    ),\n    (\n        \"seven://{}/15551\".format(\"a\" * 25),\n        {\n            # target phone number invalid\n            \"instance\": NotifySeven,\n            # Our call to notify() under the hood will fail\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"seven://{}/15551232000?from=apprise\".format(\"3\" * 14),\n        {\n            # valid number, utilizing the optional from= variable\n            \"instance\": NotifySeven,\n        },\n    ),\n    (\n        \"seven://{}/15551232000?source=apprise\".format(\"3\" * 14),\n        {\n            # valid number, utilizing the optional source= variable (same as\n            # from)\n            \"instance\": NotifySeven,\n        },\n    ),\n    (\n        \"seven://{}/15551232000?from=apprise&flash=true\".format(\"3\" * 14),\n        {\n            # valid number, utilizing the optional from= variable\n            \"instance\": NotifySeven,\n        },\n    ),\n    (\n        \"seven://{}/15551232000?source=apprise&flash=true\".format(\"3\" * 14),\n        {\n            # valid number, utilizing the optional source= variable (same as\n            # from)\n            \"instance\": NotifySeven,\n        },\n    ),\n    (\n        \"seven://{}/15551232000?source=AR&flash=1&label=123\".format(\"3\" * 14),\n        {\n            # valid number, utilizing the optional source= variable (same as\n            # from)\n            \"instance\": NotifySeven,\n        },\n    ),\n)\n\n\ndef test_plugin_seven_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_seven_edge_cases(mock_post):\n    \"\"\"NotifySeven() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n    # Prepare Mock\n    mock_post.return_value = response\n    source = \"+1 (555) 123-3456\"\n    # No apikey specified\n    with pytest.raises(TypeError):\n        NotifySeven(apikey=None, source=source)\n"
  },
  {
    "path": "tests/test_plugin_sfr.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.sfr import NotifySFR\n\nlogging.disable(logging.CRITICAL)\n\nSFR_GOOD_RESPONSE = json.dumps({\n    \"success\": True,\n    \"reponse\": 8888888,\n})\n\nSFR_BAD_RESPONSE = json.dumps({\n    \"success\": False,\n    \"errorCode\": \"THIS_IS_AN_ERROR\",\n    \"errorDetail\": \"Appel api en erreur\",\n    \"fatal\": True,\n    \"invalidParams\": True,\n})\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"sfr://\",\n        {\n            # No host specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://:@/\",\n        {\n            # Invalid host\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://:service_password\",\n        {\n            # No user specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://testing:serv@ice_password\",\n        {\n            # Invalid Password\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://testing:service_password@/5555555555\",\n        {\n            # No spaceId provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://testing:service_password@12345/\",\n        {\n            # No target provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"sfr://:service_password@12345/{3 * 13}\",\n        {\n            # No host but everything else provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://:service_password@space_id/targets?media=TEST\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://service_id:\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://service_id:@\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://service_id:@{}\".format(\"0\" * 3),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://service_id:@{}/\".format(\"0\" * 3),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://service_id:@{}/targets\".format(\"0\" * 3),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://service_id:@{}/targets?media=TEST\".format(\"0\" * 3),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sfr://service_id:service_password@{}/{}?from=MyApp&timeout=30\".format(\n            \"0\" * 3, \"0\" * 10\n        ),\n        {\n            # a valid group\n            \"instance\": NotifySFR,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"sfr://service_id:****@0...0/0000000000?\"\n                \"from=MyApp&timeout=30&voice=claire08s&\"\n                \"lang=fr_FR&media=SMSUnicode\"\n            ),\n            # Our response expected server response\n            \"requests_response_text\": SFR_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"sfr://service_id:service_password@{}/{}?voice=laura8k&lang=en_US\"\n        .format(\"0\" * 3, \"0\" * 10),\n        {\n            # a valid group\n            \"instance\": NotifySFR,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"sfr://service_id:****@0...0/0000000000?\"\n                \"from=&timeout=2880&voice=laura8k&\"\n                \"lang=en_US&media=SMSUnicode\"\n            ),\n            # Our response expected server response\n            \"requests_response_text\": SFR_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"sfr://service_id:service_password@{}/{}?media=SMS\".format(\n            \"0\" * 3, \"0\" * 10\n        ),\n        {\n            # a valid group\n            \"instance\": NotifySFR,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"sfr://service_id:****@0...0/0000000000?\"\n                \"from=&timeout=2880&voice=claire08s&\"\n                \"lang=fr_FR&media=SMS\"\n            ),\n            # Our response expected server response\n            \"requests_response_text\": SFR_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"sfr://service_id:service_password@{}/{}\".format(\"0\" * 3, \"0\" * 10),\n        {\n            # Test case where we get a bad response\n            \"instance\": NotifySFR,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": (\n                \"sfr://service_id:****@0...0/0000000000?\"\n                \"from=&timeout=2880&voice=claire08s&\"\n                \"lang=fr_FR&media=SMSUnicode\"\n            ),\n            # Our failed notification expected server response\n            \"requests_response_text\": SFR_BAD_RESPONSE,\n            \"requests_response_code\": requests.codes.ok,\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n)\n\n\ndef test_plugin_sfr_urls():\n    \"\"\"NotifySFR() Apprise URLs.\"\"\"\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sfr_notification_ok(mock_post):\n    \"\"\"NotifySFR() Notifications Ok response.\"\"\"\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = SFR_GOOD_RESPONSE\n    mock_post.return_value = response\n\n    # Test our URL parsing\n    results = NotifySFR.parse_url(\n        \"sfr://srv:pwd@{}/{}?media=SMSLong\".format(\"1\" * 8, \"0\" * 10)\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"srv\"\n    assert results[\"password\"] == \"pwd\"\n    assert results[\"space_id\"] == \"11111111\"\n    assert results[\"targets\"] == [\"0000000000\"]\n    assert results[\"media\"] == \"SMSLong\"\n    assert results[\"timeout\"] == \"\"\n    assert results[\"voice\"] == \"\"\n    assert results[\"lang\"] == \"\"\n    assert results[\"sender\"] == \"\"\n\n    instance = NotifySFR(**results)\n    assert isinstance(instance, NotifySFR)\n    assert len(instance) == 1\n    assert instance.lang == \"fr_FR\"\n    assert instance.lang == \"fr_FR\"\n    assert instance.sender == \"\"\n    assert isinstance(instance.targets, list)\n    assert isinstance(instance.timeout, int)\n    assert isinstance(instance.voice, str)\n    assert isinstance(instance.space_id, str)\n\n    response = instance.send(body=\"test\")\n    assert response is True\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sfr_notification_multiple_targets_ok(mock_post):\n    \"\"\"NotifySFR() Notifications ko response.\"\"\"\n    # Reset our object\n    mock_post.reset_mock()\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = SFR_GOOD_RESPONSE\n    mock_post.return_value = response\n\n    # Test \"real\" parameters\n    results = NotifySFR.parse_url(\n        \"sfr://{}:other_fjv&8password@{}/?to={},{}&from=MyCustomUser\".format(\n            \"4\" * 6, \"1\" * 8, \"6\" * 10, \"8\" * 10\n        )\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"444444\"\n    assert results[\"password\"] == \"other_fjv&8password\"\n    assert results[\"space_id\"] == \"11111111\"\n    assert results[\"targets\"] == [\"6666666666\", \"8888888888\"]\n    assert results[\"media\"] == \"\"\n    assert results[\"timeout\"] == \"\"\n    assert results[\"voice\"] == \"\"\n    assert results[\"lang\"] == \"\"\n    assert results[\"sender\"] == \"MyCustomUser\"\n\n    instance = NotifySFR(**results)\n    assert isinstance(instance, NotifySFR)\n    assert len(instance) == 2\n    assert instance.lang == \"fr_FR\"\n    assert instance.sender == \"MyCustomUser\"\n    assert instance.media == \"SMSUnicode\"\n    assert isinstance(instance.targets, list)\n    assert instance.timeout == 2880\n    assert instance.voice == \"claire08s\"\n    assert isinstance(instance.space_id, str)\n\n    response = instance.send(body=\"test\")\n    assert response is True\n    assert mock_post.call_count == 2\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sfr_notification_ko(mock_post):\n    \"\"\"NotifySFR() Notifications ko response.\"\"\"\n    # Reset our object\n    mock_post.reset_mock()\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = SFR_BAD_RESPONSE\n    mock_post.return_value = response\n\n    # Test \"real\" parameters\n    results = NotifySFR.parse_url(\n        \"sfr://{}:other_fjv&8password@{}/{}?timeout=30&media=SMS\".format(\n            \"4\" * 6, \"1\" * 8, \"2\" * 10\n        )\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"444444\"\n    assert results[\"password\"] == \"other_fjv&8password\"\n    assert results[\"space_id\"] == \"11111111\"\n    assert results[\"media\"] == \"SMS\"\n    assert results[\"targets\"] == [\"2222222222\"]\n    assert results[\"timeout\"] == \"30\"\n    assert results[\"voice\"] == \"\"\n    assert results[\"lang\"] == \"\"\n    assert results[\"sender\"] == \"\"\n\n    instance = NotifySFR(**results)\n    assert isinstance(instance, NotifySFR)\n    assert len(instance) == 1\n    assert instance.lang == \"fr_FR\"\n    assert instance.sender == \"\"\n    assert instance.media == \"SMS\"\n    assert isinstance(instance.targets, list)\n    assert instance.timeout == 30\n    assert instance.voice == \"claire08s\"\n    assert isinstance(instance.space_id, str)\n\n    response = instance.send(body=\"test\")\n    assert response is False\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sfr_notification_multiple_targets_all_ko(mock_post):\n    \"\"\"NotifySFR() Notifications ko response.\"\"\"\n    # Reset our object\n    mock_post.reset_mock()\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = SFR_BAD_RESPONSE\n    mock_post.return_value = response\n\n    # Test \"real\" parameters\n    results = NotifySFR.parse_url(\n        \"sfr://{}:other_fjv&8password@{}/?to={},{}&voice=laura8k\".format(\n            \"4\" * 6, \"1\" * 8, \"6\" * 4, \"8\" * 4\n        )\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"444444\"\n    assert results[\"password\"] == \"other_fjv&8password\"\n    assert results[\"space_id\"] == \"11111111\"\n    assert results[\"targets\"] == [\"6666\", \"8888\"]\n    assert results[\"voice\"] == \"laura8k\"\n    assert results[\"media\"] == \"\"\n    assert results[\"timeout\"] == \"\"\n    assert results[\"lang\"] == \"\"\n    assert results[\"sender\"] == \"\"\n\n    # No valid phone number provided\n    with pytest.raises(TypeError):\n        NotifySFR(**results)\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sfr_notification_multiple_targets_one_ko(mock_post):\n    \"\"\"NotifySFR() Notifications ko response.\"\"\"\n    # Reset our object\n    mock_post.reset_mock()\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = SFR_BAD_RESPONSE\n    mock_post.return_value = response\n\n    # Test \"real\" parameters\n    results = NotifySFR.parse_url(\n        \"sfr://{}:&pass@{}/?to={},{}&media=SMSUnicodeLong&lang=en_US\".format(\n            \"4\" * 6, \"1\" * 8, \"6\" * 10, \"8\" * 4\n        )\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"444444\"\n    assert results[\"password\"] == \"&pass\"\n    assert results[\"space_id\"] == \"11111111\"\n    assert results[\"targets\"] == [\"6666666666\", \"8888\"]\n    assert results[\"voice\"] == \"\"\n    assert results[\"media\"] == \"SMSUnicodeLong\"\n    assert results[\"timeout\"] == \"\"\n    assert results[\"lang\"] == \"en_US\"\n    assert results[\"sender\"] == \"\"\n\n    instance = NotifySFR(**results)\n    assert isinstance(instance, NotifySFR)\n    assert len(instance) == 1\n    assert instance.lang == \"en_US\"\n    assert instance.sender == \"\"\n    assert instance.media == \"SMSUnicodeLong\"\n    assert isinstance(instance.targets, list)\n    assert instance.timeout == 2880\n    assert instance.voice == \"claire08s\"\n    assert isinstance(instance.space_id, str)\n\n    # One phone number failed to be parsed, therefore notify fails\n    response = instance.send(body=\"test\")\n    assert response is False\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sfr_notification_exceptions(mock_post):\n    \"\"\"NotifySFR() Notifications exceptions.\"\"\"\n    mock_post.reset_mock()\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.internal_server_error\n    response.content = SFR_GOOD_RESPONSE\n    mock_post.return_value = response\n\n    # Test \"real\" parameters\n    results = NotifySFR.parse_url(\n        \"sfr://{}:str0*fn_ppw0rd@{}/{}\".format(\n            \"404ghwo89144\", \"9993384\", \"0959290404\"\n        )\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"404ghwo89144\"\n    assert results[\"password\"] == \"str0*fn_ppw0rd\"\n    assert results[\"space_id\"] == \"9993384\"\n    assert results[\"targets\"] == [\"0959290404\"]\n    assert results[\"media\"] == \"\"\n    assert results[\"timeout\"] == \"\"\n    assert results[\"lang\"] == \"\"\n    assert results[\"sender\"] == \"\"\n\n    instance = NotifySFR(**results)\n    assert isinstance(instance, NotifySFR)\n    assert len(instance) == 1\n    assert instance.lang == \"fr_FR\"\n    assert instance.sender == \"\"\n    assert instance.media == \"SMSUnicode\"\n    assert isinstance(instance.targets, list)\n    assert instance.timeout == 2880\n    assert instance.voice == \"claire08s\"\n    assert isinstance(instance.space_id, str)\n\n    response = instance.send(body=\"test\")\n    # Must return False\n    assert response is False\n    assert mock_post.call_count == 1\n\n    # Test invalid content returned by requests\n    mock_post.reset_mock()\n    response = mock.Mock()\n    response.status_code = requests.codes.ok\n    response.content = b\"Invalid JSON Content\"\n    mock_post.return_value = response\n\n    # Test \"real\" parameters\n    results = NotifySFR.parse_url(\n        \"sfr://{}:str0*fn_ppw0rd@{}/{}\".format(\n            \"404ghwo89144\", \"9993384\", \"0959290404\"\n        )\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"404ghwo89144\"\n    assert results[\"password\"] == \"str0*fn_ppw0rd\"\n    assert results[\"space_id\"] == \"9993384\"\n    assert results[\"targets\"] == [\"0959290404\"]\n    assert results[\"media\"] == \"\"\n    assert results[\"timeout\"] == \"\"\n    assert results[\"lang\"] == \"\"\n    assert results[\"sender\"] == \"\"\n\n    instance = NotifySFR(**results)\n    assert isinstance(instance, NotifySFR)\n    assert len(instance) == 1\n    assert instance.lang == \"fr_FR\"\n    assert instance.sender == \"\"\n    assert instance.media == \"SMSUnicode\"\n    assert isinstance(instance.targets, list)\n    assert instance.timeout == 2880\n    assert instance.voice == \"claire08s\"\n    assert isinstance(instance.space_id, str)\n\n    response = instance.send(body=\"test\")\n    # Must return False\n    assert response is False\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\n    \"requests.post\",\n    side_effect=requests.RequestException(\"Connection error\"),\n)\ndef test_plugin_sfr_notification_exceptions_requests(mock_post):\n    \"\"\"NotifySFR() Notifications requests exceptions.\"\"\"\n    # Test requests socket error return\n    mock_post.reset_mock()\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.internal_server_error\n    response.content = b\"Invalid content\"\n    mock_post.return_value = response\n\n    # Test \"real\" parameters\n    results = NotifySFR.parse_url(\n        \"sfr://{}:str0*fn_ppw0rd@{}/{}\".format(\n            \"404ghwo89144\", \"9993384\", \"0959290404\"\n        )\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"404ghwo89144\"\n    assert results[\"password\"] == \"str0*fn_ppw0rd\"\n    assert results[\"space_id\"] == \"9993384\"\n    assert results[\"targets\"] == [\"0959290404\"]\n    assert results[\"media\"] == \"\"\n    assert results[\"timeout\"] == \"\"\n    assert results[\"lang\"] == \"\"\n    assert results[\"sender\"] == \"\"\n\n    instance = NotifySFR(**results)\n    assert isinstance(instance, NotifySFR)\n    assert len(instance) == 1\n    assert instance.lang == \"fr_FR\"\n    assert instance.sender == \"\"\n    assert instance.media == \"SMSUnicode\"\n    assert isinstance(instance.targets, list)\n    assert instance.timeout == 2880\n    assert instance.voice == \"claire08s\"\n    assert isinstance(instance.space_id, str)\n\n    response = instance.send(body=\"test\")\n    # Must return False do to requests error\n    assert response is False\n    assert mock_post.call_count == 1\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sfr_failure(mock_post):\n    \"\"\"NotifySFR() Failure Cases.\"\"\"\n    mock_post.reset_mock()\n    # Prepare Mock\n    # Create a mock response object\n    response = mock.Mock()\n    response.status_code = requests.codes.no_content\n    mock_post.return_value = response\n\n    # Invalid service_id\n    with pytest.raises(TypeError):\n        NotifySFR(\n            user=None,\n            password=\"service_password\",\n            space_id=int(\"8\" * 10),\n            targets=int(\"8\" * 10),\n        )\n\n    # Invalid service_password\n    with pytest.raises(TypeError):\n        NotifySFR(\n            user=\"service_id\",\n            password=None,\n            space_id=int(\"8\" * 10),\n            targets=int(\"8\" * 10),\n        )\n\n    # Invalid space_id\n    with pytest.raises(TypeError):\n        NotifySFR(\n            user=\"service_id\",\n            password=\"service_password\",\n            space_id=None,\n            targets=int(\"8\" * 10),\n        )\n\n    # Invalid targets\n    with pytest.raises(TypeError):\n        NotifySFR(\n            user=\"service_id\",\n            password=\"service_password\",\n            space_id=int(\"8\" * 10),\n            targets=None,\n        )\n"
  },
  {
    "path": "tests/test_plugin_signal.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.config import ConfigBase\nfrom apprise.plugins.base import NotifyFormat\nfrom apprise.plugins.signal_api import NotifySignalAPI\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n\n@pytest.fixture\ndef request_mock(mocker):\n    \"\"\"Prepare requests mock.\"\"\"\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = \"\"\n    return mock_post\n\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"signal://\",\n        {\n            # No host specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signal://:@/\",\n        {\n            # invalid host\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signal://localhost\",\n        {\n            # Just a host provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signal://localhost\",\n        {\n            # key and secret provided and from but invalid from no\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signal://localhost/123\",\n        {\n            # invalid from phone\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signal://localhost/{}/123/\".format(\"1\" * 11),\n        {\n            # invalid 'to' phone number\n            \"instance\": NotifySignalAPI,\n            # Notify will fail because it couldn't send to anyone\n            \"response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"signal://localhost/+{}/123\".format(\"1\" * 11),\n        },\n    ),\n    (\n        \"signal://localhost:8080/{}/\".format(\"1\" * 11),\n        {\n            # one phone number will notify ourselves\n            \"instance\": NotifySignalAPI,\n        },\n    ),\n    (\n        \"signal://localhost:8082/+{}/@group.abcd/\".format(\"2\" * 11),\n        {\n            # a valid group\n            \"instance\": NotifySignalAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"signal://localhost:8082/+{}/@abcd\".format(\n                \"2\" * 11\n            ),\n        },\n    ),\n    (\n    \"signals://localhost/{}/{}?format=markdown\".format(\"1\" * 11, \"3\" * 11),\n    {\n        # Test our markdown flag\n        \"instance\": NotifySignalAPI,\n    },\n    ),\n    (\n        \"signal://localhost:8080/+{}/group.abcd/\".format(\"1\" * 11),\n        {\n            # another valid group (without @ symbol)\n            \"instance\": NotifySignalAPI,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"signal://localhost:8080/+{}/@abcd\".format(\n                \"1\" * 11\n            ),\n        },\n    ),\n    (\n        \"signal://localhost:8080/?from={}&to={},{}\".format(\n            \"1\" * 11, \"2\" * 11, \"3\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifySignalAPI,\n        },\n    ),\n    (\n        \"signal://localhost:8080/?from={}&to={},{},{}\".format(\n            \"1\" * 11, \"2\" * 11, \"3\" * 11, \"5\" * 3\n        ),\n        {\n            # 2 good targets and one invalid one\n            \"instance\": NotifySignalAPI,\n        },\n    ),\n    (\n        \"signal://localhost:8080/{}/{}/?from={}\".format(\n            \"1\" * 11, \"2\" * 11, \"3\" * 11\n        ),\n        {\n            # If we have from= specified, then all elements take on the to=\n            # value\n            \"instance\": NotifySignalAPI,\n        },\n    ),\n    (\n        \"signals://user@localhost/{}/{}\".format(\"1\" * 11, \"3\" * 11),\n        {\n            # use get args to acomplish the same thing (use source instead of\n            # from)\n            \"instance\": NotifySignalAPI,\n            # Run through code with debug logging enabled\n            \"force_debug\": True,\n        },\n    ),\n    (\n        \"signals://user:password@localhost/{}/{}\".format(\"1\" * 11, \"3\" * 11),\n        {\n            # use get args to acomplish the same thing (use source instead of\n            # from)\n            \"instance\": NotifySignalAPI,\n        },\n    ),\n    (\n        \"signals://user:password@localhost/{}/{}\".format(\"1\" * 11, \"3\" * 11),\n        {\n            \"instance\": NotifySignalAPI,\n            # Test that a 201 response code is still accepted\n            \"requests_response_code\": 201,\n        },\n    ),\n    (\n        \"signals://localhost/{}/{}/{}?batch=True\".format(\n            \"1\" * 11, \"3\" * 11, \"4\" * 11\n        ),\n        {\n            # test batch mode\n            \"instance\": NotifySignalAPI,\n        },\n    ),\n    (\n        \"signals://localhost/{}/{}/{}?status=True\".format(\n            \"1\" * 11, \"3\" * 11, \"4\" * 11\n        ),\n        {\n            # test status switch\n            \"instance\": NotifySignalAPI,\n        },\n    ),\n    (\n        \"signal://localhost/{}/{}\".format(\"1\" * 11, \"4\" * 11),\n        {\n            \"instance\": NotifySignalAPI,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"signal://localhost/{}/{}\".format(\"1\" * 11, \"4\" * 11),\n        {\n            \"instance\": NotifySignalAPI,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_signal_urls():\n    \"\"\"NotifySignalAPI() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_signal_edge_cases(request_mock):\n    \"\"\"NotifySignalAPI() Edge Cases.\"\"\"\n    # Initialize some generic (but valid) tokens\n    source = \"+1 (555) 123-3456\"\n    target = \"+1 (555) 987-5432\"\n    body = \"test body\"\n    title = \"My Title\"\n\n    # No apikey specified\n    with pytest.raises(TypeError):\n        NotifySignalAPI(source=None)\n\n    aobj = Apprise()\n    assert aobj.add(f\"signals://localhost:231/{source}/{target}\")\n    assert aobj.notify(title=title, body=body)\n\n    assert request_mock.call_count == 1\n\n    details = request_mock.call_args_list[0]\n    assert details[0][0] == \"https://localhost:231/v2/send\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"message\"] == \"My Title\\r\\ntest body\"\n\n    # Reset our mock object\n    request_mock.reset_mock()\n\n    aobj = Apprise()\n    assert aobj.add(\n        f\"signals://user@localhost:231/{source}/{target}?status=True\"\n    )\n    assert aobj.notify(title=title, body=body)\n\n    assert request_mock.call_count == 1\n\n    details = request_mock.call_args_list[0]\n    assert details[0][0] == \"https://localhost:231/v2/send\"\n    payload = loads(details[1][\"data\"])\n    # Status flag is set\n    assert payload[\"message\"] == \"[i] My Title\\r\\ntest body\"\n\n\ndef test_plugin_signal_yaml_config(request_mock):\n    \"\"\"NotifySignalAPI() YAML Configuration.\"\"\"\n\n    # Load our configuration\n    result, _ = ConfigBase.config_parse_yaml(cleandoc(\"\"\"\n    urls:\n      - signal://signal:8080/+1234567890:\n         - to: +0987654321\n           tag: signal\n    \"\"\"))\n\n    # Verify we loaded correctly\n    assert isinstance(result, list)\n    assert len(result) == 1\n    assert len(result[0].tags) == 1\n    assert \"signal\" in result[0].tags\n\n    # Let's get our plugin\n    plugin = result[0]\n    assert len(plugin.targets) == 1\n    assert plugin.source == \"+1234567890\"\n    assert \"+0987654321\" in plugin.targets\n\n    #\n    # Test another way to get the same results\n    #\n\n    # Load our configuration\n    result, _config = ConfigBase.config_parse_yaml(cleandoc(\"\"\"\n    urls:\n      - signal://signal:8080/+1234567890/+0987654321:\n         - tag: signal\n    \"\"\"))\n\n    # Verify we loaded correctly\n    assert isinstance(result, list)\n    assert len(result) == 1\n    assert len(result[0].tags) == 1\n    assert \"signal\" in result[0].tags\n\n    # Let's get our plugin\n    plugin = result[0]\n    assert len(plugin.targets) == 1\n    assert plugin.source == \"+1234567890\"\n    assert \"+0987654321\" in plugin.targets\n\n\ndef test_plugin_signal_based_on_feedback(request_mock):\n    \"\"\"NotifySignalAPI() User Feedback Test.\"\"\"\n    body = \"test body\"\n    title = \"My Title\"\n\n    aobj = Apprise()\n    aobj.add(\n        \"signal://10.0.0.112:8080/+12512222222/+12513333333/\"\n        \"12514444444?batch=yes\"\n    )\n\n    assert aobj.notify(title=title, body=body)\n\n    # If a batch, there is only 1 post\n    assert request_mock.call_count == 1\n\n    details = request_mock.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/v2/send\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"message\"] == \"My Title\\r\\ntest body\"\n    assert payload[\"number\"] == \"+12512222222\"\n    assert len(payload[\"recipients\"]) == 2\n    assert \"+12513333333\" in payload[\"recipients\"]\n    # The + is appended\n    assert \"+12514444444\" in payload[\"recipients\"]\n\n    # Reset our test and turn batch mode off\n    request_mock.reset_mock()\n\n    aobj = Apprise()\n    aobj.add(\n        \"signal://10.0.0.112:8080/+12512222222/+12513333333/\"\n        \"12514444444?batch=no\"\n    )\n\n    assert aobj.notify(title=title, body=body)\n\n    # If a batch, there is only 1 post\n    assert request_mock.call_count == 2\n\n    details = request_mock.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/v2/send\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"message\"] == \"My Title\\r\\ntest body\"\n    assert payload[\"number\"] == \"+12512222222\"\n    assert len(payload[\"recipients\"]) == 1\n    assert \"+12513333333\" in payload[\"recipients\"]\n\n    details = request_mock.call_args_list[1]\n    assert details[0][0] == \"http://10.0.0.112:8080/v2/send\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"message\"] == \"My Title\\r\\ntest body\"\n    assert payload[\"number\"] == \"+12512222222\"\n    assert len(payload[\"recipients\"]) == 1\n\n    # The + is appended\n    assert \"+12514444444\" in payload[\"recipients\"]\n\n    request_mock.reset_mock()\n\n    # Test group names\n    aobj = Apprise()\n    aobj.add(\n        \"signal://10.0.0.112:8080/+12513333333/@group1/@group2/\"\n        \"12514444444?batch=yes\"\n    )\n\n    assert aobj.notify(title=title, body=body)\n\n    # If a batch, there is only 1 post\n    assert request_mock.call_count == 1\n\n    details = request_mock.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/v2/send\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"message\"] == \"My Title\\r\\ntest body\"\n    assert payload[\"number\"] == \"+12513333333\"\n    assert len(payload[\"recipients\"]) == 3\n    assert \"+12514444444\" in payload[\"recipients\"]\n    # our groups\n    assert \"group.group1\" in payload[\"recipients\"]\n    assert \"group.group2\" in payload[\"recipients\"]\n    # Groups are stored properly\n    assert \"/@group1\" in aobj[0].url()\n    assert \"/@group2\" in aobj[0].url()\n    # Our target phone number is also in the path\n    assert \"/+12514444444\" in aobj[0].url()\n\n\ndef test_notify_signal_plugin_attachments(request_mock):\n    \"\"\"NotifySignalAPI() Attachments.\"\"\"\n\n    obj = Apprise.instantiate(\n        \"signal://10.0.0.112:8080/+12512222222/+12513333333/\"\n        \"12514444444?batch=no\"\n    )\n    assert isinstance(obj, NotifySignalAPI)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # Test Valid Attachment (load 3)\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n\n    # Return our good configuration\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # test the handling of our batch modes\n    obj = Apprise.instantiate(\n        \"signal://10.0.0.112:8080/+12512222222/+12513333333/\"\n        \"12514444444?batch=yes\"\n    )\n    assert isinstance(obj, NotifySignalAPI)\n\n    # Now send an attachment normally without issues\n    request_mock.reset_mock()\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert request_mock.call_count == 1\n\n\ndef test_plugin_signal_text_mode_markdown_from_url(request_mock):\n    \"\"\"NotifySignalAPI() sets text_mode=styled when ?format=markdown\"\"\"\n    source = \"+1 (555) 123-3456\"\n    target = \"+1 (555) 987-5432\"\n    body = \"Body **bold** _italic_\"\n    title = \"Title\"\n\n    aobj = Apprise()\n    # Use URL path tokens, add the markdown format via query string\n    assert aobj.add(\n        f\"signals://localhost:231/{source}/{target}?format=markdown\")\n    assert aobj.notify(title=title, body=body)\n\n    assert request_mock.call_count == 1\n    details = request_mock.call_args_list[0]\n\n    payload = loads(details[1][\"data\"])\n    # Core behaviour we are validating\n    assert payload.get(\"text_mode\") == \"styled\"\n\n\ndef test_plugin_signal_text_mode_markdown_from_library(request_mock):\n    \"\"\"NotifySignalAPI() sets text_mode=styled when class format=MARKDOWN\"\"\"\n    source = \"+1 (555) 123-3456\"\n    target = \"+1 (555) 987-5432\"\n\n    obj = NotifySignalAPI(\n        host=\"localhost\",\n        port=231,\n        secure=True,\n        source=source,\n        targets=[target],\n        format=NotifyFormat.MARKDOWN,\n    )\n\n    assert obj.notify(title=\"Title\", body=\"Body **bold** _italic_\") is True\n\n    assert request_mock.call_count == 1\n    details = request_mock.call_args_list[0]\n\n    payload = loads(details[1][\"data\"])\n    # Core behaviour we are validating\n    assert payload.get(\"text_mode\") == \"styled\"\n"
  },
  {
    "path": "tests/test_plugin_signl4.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.# BSD 2-Clause License\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.signl4 import (\n    NotifySIGNL4,\n    NotifyType,\n)\n\nlogging.disable(logging.CRITICAL)\n\nSIGNL4_GOOD_RESPONSE = dumps({\n    \"eventId\": \"2516485120936941747_76d5cf30-27d2-4529-84ed-f31a8f2c72b1\",\n})\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"signl4://\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signl4://:@/\",\n        {\n            # We failed to identify any valid authentication\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signl4://%20%20/\",\n        {\n            # invalid secret specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"signl4://secret/\",\n        {\n            # No targets specified; this is allowed\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"signl4://?secret=secret\",\n        {\n            # No targets specified; this is allowed\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"signl4://secret/?service=IoT\",\n        {\n            # European Region\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"signl4://secret/?filtering=yes\",\n        {\n            # European Region\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"signl4://secret/?location=40.6413111,-73.7781391\",\n        {\n            # European Region\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"signl4://secret/?alerting_scenario=singl4_ack\",\n        {\n            # European Region\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"signl4://secret/?filtering=False\",\n        {\n            # European Region\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n        (\n        \"signl4://secret/?external_id=ar1234&status=new\",\n        {\n            # European Region\n            \"instance\": NotifySIGNL4,\n            \"notify_type\": NotifyType.FAILURE,\n            # Our response expected server response\n            \"requests_response_text\": SIGNL4_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"signl4://secret/\",\n        {\n            \"instance\": NotifySIGNL4,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"signl4://secret/\",\n        {\n            \"instance\": NotifySIGNL4,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_signl4_urls():\n    \"\"\"NotifySIGNL4() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_simplepush.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nimport sys\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.simplepush import NotifySimplePush\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"spush://\",\n        {\n            # No api key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"spush://{}\".format(\"A\" * 14),\n        {\n            # API Key specified however expected server response\n            # didn't have 'OK' in JSON response\n            \"instance\": NotifySimplePush,\n            # Expected notify() response\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"spush://{}\".format(\"Y\" * 14),\n        {\n            # API Key valid and expected response was valid\n            \"instance\": NotifySimplePush,\n            # Set our response to OK\n            \"requests_response_text\": {\n                \"status\": \"OK\",\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"spush://Y...Y/\",\n        },\n    ),\n    (\n        \"spush://{}?event=Not%20So%20Good\".format(\"X\" * 14),\n        {\n            # API Key valid and expected response was valid\n            \"instance\": NotifySimplePush,\n            # Set our response to something that is not okay\n            \"requests_response_text\": {\n                \"status\": \"NOT-OK\",\n            },\n            # Expected notify() response\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"spush://salt:pass@{}\".format(\"X\" * 14),\n        {\n            # Now we'll test encrypted messages with new salt\n            \"instance\": NotifySimplePush,\n            # Set our response to OK\n            \"requests_response_text\": {\n                \"status\": \"OK\",\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"spush://****:****@X...X/\",\n        },\n    ),\n    (\n        \"spush://{}\".format(\"Y\" * 14),\n        {\n            \"instance\": NotifySimplePush,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            # Set a failing message too\n            \"requests_response_text\": {\n                \"status\": \"BadRequest\",\n                \"message\": \"Title or message too long\",\n            },\n        },\n    ),\n    (\n        \"spush://{}\".format(\"Z\" * 14),\n        {\n            \"instance\": NotifySimplePush,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_simplepush_urls():\n    \"\"\"NotifySimplePush() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@pytest.mark.skipif(\n    \"cryptography\" in sys.modules,\n    reason=\"Requires that cryptography NOT be installed\")\ndef test_plugin_simpepush_cryptography_import_error():\n    \"\"\"\n    NotifySimplePush() Cryptography loading failure\n    \"\"\"\n\n    # Attempt to instantiate our object\n    obj = Apprise.instantiate(\"spush://{}\".format(\"Y\" * 14))\n\n    # It's not possible because our cryptography depedancy is missing\n    assert obj is None\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\")\ndef test_plugin_simplepush_edge_cases():\n    \"\"\"\n    NotifySimplePush() Edge Cases\n\n    \"\"\"\n\n    # No token\n    with pytest.raises(TypeError):\n        NotifySimplePush(apikey=None)\n\n    with pytest.raises(TypeError):\n        NotifySimplePush(apikey=\"  \")\n\n    # Bad event\n    with pytest.raises(TypeError):\n        NotifySimplePush(apikey=\"abc\", event=object)\n\n    with pytest.raises(TypeError):\n        NotifySimplePush(apikey=\"abc\", event=\"  \")\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\n@mock.patch(\"requests.post\")\ndef test_plugin_simplepush_general(mock_post):\n    \"\"\"NotifySimplePush() General Tests.\"\"\"\n\n    # Prepare a good response\n    response = mock.Mock()\n    response.content = json.dumps({\n        \"status\": \"OK\",\n    })\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n\n    obj = Apprise.instantiate(\"spush://{}\".format(\"Y\" * 14))\n\n    # Verify our content works as expected\n    assert obj.notify(title=\"test\", body=\"test\") is True\n"
  },
  {
    "path": "tests/test_plugin_sinch.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.sinch import NotifySinch\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"sinch://\",\n        {\n            # No Account SID specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sinch://:@/\",\n        {\n            # invalid Auth token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sinch://{}@12345678\".format(\"a\" * 32),\n        {\n            # Just spi provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sinch://{}:{}@_\".format(\"a\" * 32, \"b\" * 32),\n        {\n            # spi and token provided but invalid from\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"3\" * 5),\n        {\n            # using short-code (5 characters) without a target\n            # We can still instantiate ourselves with a valid short code\n            \"instance\": NotifySinch,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"3\" * 9),\n        {\n            # spi and token provided and from but invalid from no\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}/123/{}/abcd/\".format(\n            \"a\" * 32, \"b\" * 32, \"3\" * 11, \"9\" * 15\n        ),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifySinch,\n        },\n    ),\n    (\n        \"sinch://{}:{}@12345/{}\".format(\"a\" * 32, \"b\" * 32, \"4\" * 11),\n        {\n            # using short-code (5 characters)\n            \"instance\": NotifySinch,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"sinch://...aaaa:b...b@12345\",\n        },\n    ),\n    (\n        \"sinch://{}:{}@123456/{}\".format(\"a\" * 32, \"b\" * 32, \"4\" * 11),\n        {\n            # using short-code (6 characters)\n            \"instance\": NotifySinch,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"5\" * 11),\n        {\n            # using phone no with no target - we text ourselves in\n            # this case\n            \"instance\": NotifySinch,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}?region=eu\".format(\"a\" * 32, \"b\" * 32, \"5\" * 11),\n        {\n            # Specify a region\n            \"instance\": NotifySinch,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}?region=invalid\".format(\"a\" * 32, \"b\" * 32, \"5\" * 11),\n        {\n            # Invalid region\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sinch://_?spi={}&token={}&from={}\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifySinch,\n        },\n    ),\n    (\n        \"sinch://_?spi={}&token={}&source={}\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing (use source instead of\n            # from)\n            \"instance\": NotifySinch,\n        },\n    ),\n    (\n        \"sinch://_?spi={}&token={}&from={}&to={}\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11, \"7\" * 13\n        ),\n        {\n            # use to=\n            \"instance\": NotifySinch,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"6\" * 11),\n        {\n            \"instance\": NotifySinch,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"sinch://{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"6\" * 11),\n        {\n            \"instance\": NotifySinch,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_sinch_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sinch_edge_cases(mock_post):\n    \"\"\"NotifySinch() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    service_plan_id = \"{}\".format(\"b\" * 32)\n    api_token = \"{}\".format(\"b\" * 32)\n    source = \"+1 (555) 123-3456\"\n\n    # No service_plan_id specified\n    with pytest.raises(TypeError):\n        NotifySinch(service_plan_id=None, api_token=api_token, source=source)\n\n    # No api_token specified\n    with pytest.raises(TypeError):\n        NotifySinch(\n            service_plan_id=service_plan_id, api_token=None, source=source\n        )\n\n    # a error response\n    response.status_code = 400\n    response.content = dumps({\n        \"code\": 21211,\n        \"message\": \"The 'To' number +1234567 is not a valid phone number.\",\n    })\n    mock_post.return_value = response\n\n    # Initialize our object\n    obj = NotifySinch(\n        service_plan_id=service_plan_id, api_token=api_token, source=source\n    )\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n"
  },
  {
    "path": "tests/test_plugin_slack.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.slack import NotifySlack\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"slack://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://T1JJ3T3L2\",\n        {\n            # Just Token 1 provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://T1JJ3T3L2/A1BRTD4JD/\",\n        {\n            # Just 2 tokens provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/?mode=invalid\",\n        {\n            # invalid Mode provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#hmm/#-invalid-\",\n        {\n            # No username specified; this is still okay as we sub in\n            # default; The one invalid channel is skipped when sending a\n            # message\n            \"instance\": NotifySlack,\n            # There is an invalid channel that we will fail to deliver to\n            # as a result the response type will be false\n            \"response\": False,\n            \"requests_response_text\": {\n                \"ok\": False,\n                \"message\": \"Bad Channel\",\n            },\n        },\n    ),\n    (\n        \"slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#channel\",\n        {\n            # No username specified; this is still okay as we sub in\n            # default; The one invalid channel is skipped when sending a\n            # message\n            \"instance\": NotifySlack,\n            # don't include an image by default\n            \"include_image\": False,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        (\n            \"slack://username@xoxe.xoxb-1234-1234-abc124/#nuxref?footer=no\"\n            \"&timestamp=yes\"\n        ),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n        },\n    ),\n    (\n        (\n            \"slack://username@xoxe.xoxp-1234-1234-abc124/#nuxref?footer=yes\"\n            \"&timestamp=no\"\n        ),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n        },\n    ),\n    # Test using a rotating bot-token as argument\n    (\n        (\n            \"slack://?token=xoxe.xoxb-1234-1234-abc124&to=#nuxref&footer=no\"\n            \"&user=test\"\n        ),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n            \"privacy_url\": \"slack://test@x...4/nuxref/\",\n        },\n    ),\n    (\n        \"slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+id/@id/\",\n        {\n            # + encoded id,\n            # @ userid\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        (\n            \"slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"?to=#nuxref\"\n        ),\n        {\n            \"instance\": NotifySlack,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"slack://username@T...2/A...D/T...Q/\",\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        (\"slack://username@T1JJ3T3L2/A1BRTD4JD/\"\n         \"TIiajkdnlazkcOXrIdevi7FQ/#nuxref\"),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    # You can't send to email using webhook\n    (\n        \"slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnl/user@gmail.com\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n            # we'll have a notify response failure in this case\n            \"notify_response\": False,\n        },\n    ),\n    # Specify Token on argument string (with username)\n    (\n        \"slack://bot@_/#nuxref?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnadfdajkjkfl/\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    # Specify Token and channels on argument string (no username)\n    (\n        (\"slack://?token=T1JJ3T3L2/A1BRTD4JD\"\n         \"/TIiajkdnlazkcOXrIdevi7FQ/&to=#chan\"),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    # Test webhook that doesn't have a proper response\n    (\n        (\"slack://username@T1JJ3T3L2/A1BRTD4JD/\"\n         \"TIiajkdnlazkcOXrIdevi7FQ/#nuxref\"),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"fail\",\n            # we'll have a notify response failure in this case\n            \"notify_response\": False,\n        },\n    ),\n    # Test using a bot-token (also test footer set to no flag)\n    (\n        (\n            \"slack://username@xoxb-1234-1234-abc124/#nuxref?footer=no\"\n            \"&timestamp=yes\"),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n        },\n    ),\n    (\n        (\n            \"slack://username@xoxb-1234-1234-abc124/#nuxref?footer=yes\"\n            \"&timestamp=yes\"),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n        },\n    ),\n    (\n        (\n            \"slack://username@xoxb-1234-1234-abc124/#nuxref?footer=yes\"\n            \"&timestamp=no\"),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n        },\n    ),\n    (\n        (\n            \"slack://username@xoxb-1234-1234-abc124/#nuxref?footer=yes\"\n            \"&timestamp=no\"),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n        },\n    ),\n    # Testing modes\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&mode=hook\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    # Forced mode on a url that does not have enough details to accommodate\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&mode=bot\"\n        ),\n        {\"instance\": TypeError},\n    ),\n    # Test blocks mode with timestamp variation\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&blocks=yes&footer=yes&timestamp=no\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    # Test blocks mode with another timestamp\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&blocks=yes&footer=yes&timestamp=yes\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    # footer being disabled means timestamp isn't shown\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&blocks=yes&footer=no&timestamp=yes\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    # footer and timestamp disabled\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&blocks=yes&footer=no&timestamp=no\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&blocks=yes&footer=yes&image=no\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&blocks=yes&format=text\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    (\n        (\n            \"slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/\"\n            \"&to=#chan&blocks=no&format=text\"\n        ),\n        {\"instance\": NotifySlack, \"requests_response_text\": \"ok\"},\n    ),\n    # Test using a bot-token as argument\n    (\n        \"slack://?token=xoxb-1234-1234-abc124&to=#nuxref&footer=no&user=test\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"slack://test@x...4/nuxref/\",\n        },\n    ),\n    # We contain 1 or more invalid channels, so we'll fail on our notify call\n    (\n        \"slack://?token=xoxb-1234-1234-abc124&to=#nuxref,#$,#-&footer=no\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n            # We fail because of the empty channel #$ and #-\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"slack://username@xoxb-1234-1234-abc124/#nuxref\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": {\n                \"ok\": True,\n                \"message\": \"\",\n            },\n            # we'll fail to send attachments because we had no 'file' response\n            # in our object\n            \"response\": False,\n        },\n    ),\n    (\n        \"slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ\",\n        {\n            # Missing a channel, falls back to webhook channel bindings\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    # Native URL Support, take the slack URL and still build from it\n    (\n        \"https://hooks.slack.com/services/{}/{}/{}\".format(\n            \"A\" * 9, \"B\" * 9, \"c\" * 24\n        ),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n            \"url_matches\": \"mode=hook\",\n        },\n    ),\n    (\n        \"https://hooks.slack-gov.com/services/{}/{}/{}\".format(\n            \"A\" * 9, \"B\" * 9, \"c\" * 24\n        ),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n            \"url_matches\": \"mode=gov-hook\",\n        },\n    ),\n    # Native URL Support with arguments\n    (\n        \"https://hooks.slack.com/services/{}/{}/{}?format=text\".format(\n            \"A\" * 9, \"B\" * 9, \"c\" * 24\n        ),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        \"slack://username@-INVALID-/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#cool\",\n        {\n            # invalid 1st Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://username@T1JJ3T3L2/-INVALID-/TIiajkdnlazkcOXrIdevi7FQ/#great\",\n        {\n            # invalid 2rd Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://username@T1JJ3T3L2/A1BRTD4JD/-INVALID-/#channel\",\n        {\n            # invalid 3rd Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"slack://l2g@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#usenet\",\n        {\n            \"instance\": NotifySlack,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        \"slack://respect@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#a\",\n        {\n            \"instance\": NotifySlack,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        \"slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b\",\n        {\n            \"instance\": NotifySlack,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        \"slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b:100\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        \"slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+124:100\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    # test a case where we have a channel defined alone (without a thread_ts)\n    # that exists after a definition where a thread_ts does exist.  this\n    # tests the branch of code that ensures we do not pass the same thread_ts\n    # twice\n    (\n        (\n            \"slack://notify@T1JJ3T3L2/A1BRTD4JD/\"\n            \"TIiajkdnlazkcOXrIdevi7FQ/+124:100/@chan\"\n        ),\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n        },\n    ),\n    (\n        \"slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b:bad\",\n        {\n            \"instance\": NotifySlack,\n            \"requests_response_text\": \"ok\",\n            # we'll fail because our thread_ts is bad\n            \"response\": False,\n        },\n    ),\n)\n\n\ndef test_plugin_slack_urls():\n    \"\"\"NotifySlack() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_slack_oauth_access_token(mock_request):\n    \"\"\"NotifySlack() OAuth Access Token Tests.\"\"\"\n\n    # Generate an invalid bot token\n    token = \"xo-invalid\"\n\n    request = mock.Mock()\n    request.content = dumps({\n        \"ok\": True,\n        \"message\": \"\",\n        \"channel\": \"C123456\",\n    })\n    request.status_code = requests.codes.ok\n\n    # We'll fail to validate the access_token\n    with pytest.raises(TypeError):\n        NotifySlack(access_token=token)\n\n    # Generate a (valid) bot token\n    token = \"xoxb-1234-1234-abc124\"\n\n    # Generate a (valid) rotating bot token\n    rotating_token = \"xoxe.xoxb-1234-1234-abc124\"\n\n    # Prepare Mock\n    mock_request.return_value = request\n    # Variation Initializations\n    obj = NotifySlack(access_token=token, targets=\"#apprise\")\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # apprise room was found\n    assert obj.send(body=\"test\") is True\n\n    # Validate rotating token is accepted too\n    obj = NotifySlack(access_token=rotating_token, targets=\"#apprise\")\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n    assert obj.send(body=\"test\") is True\n\n    # A poorly formatted xoxe prefix should still be rejected\n    with pytest.raises(TypeError):\n        NotifySlack(access_token=\"xoxe.xo-invalid\", targets=\"#apprise\")\n\n    # Test Valid Attachment\n    mock_request.reset_mock()\n    mock_request.side_effect = [\n        request,\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"upload_url\": \"https://files.slack.com/upload/v1/ABC123\",\n                \"file_id\": \"F123ABC456\",\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n        mock.Mock(\n            **{\"content\": b\"OK - 123\", \"status_code\": requests.codes.ok}\n        ),\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"files\": [{\"id\": \"F123ABC456\", \"title\": \"slack-test\"}],\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n    ]\n\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    assert mock_request.call_count == 4\n    assert mock_request.call_args_list[0][0][0] == \"post\"\n    assert (\n        mock_request.call_args_list[0][0][1]\n        == \"https://slack.com/api/chat.postMessage\"\n    )\n    assert mock_request.call_args_list[1][0][0] == \"get\"\n    assert (\n        mock_request.call_args_list[1][0][1]\n        == \"https://slack.com/api/files.getUploadURLExternal\"\n    )\n    assert mock_request.call_args_list[2][0][0] == \"post\"\n    assert (\n        mock_request.call_args_list[2][0][1]\n        == \"https://files.slack.com/upload/v1/ABC123\"\n    )\n    assert mock_request.call_args_list[3][0][0] == \"post\"\n    assert (\n        mock_request.call_args_list[3][0][1]\n        == \"https://slack.com/api/files.completeUploadExternal\"\n    )\n\n    # Test a valid attachment that throws an Connection Error\n    mock_request.return_value = None\n    mock_request.side_effect = (\n        request,\n        requests.ConnectionError(0, \"requests.ConnectionError() not handled\"),\n    )\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Test a valid attachment that throws an OSError\n    mock_request.return_value = None\n    mock_request.side_effect = (request, OSError(0, \"OSError\"))\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Reset our mock object back to how it was\n    mock_request.return_value = request\n    mock_request.side_effect = None\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    # Test case where expected return attachment payload is invalid\n    mock_request.reset_mock()\n    mock_request.side_effect = [\n        request,\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": False,\n            }),\n            \"status_code\": requests.codes.internal_server_error,\n        }),\n    ]\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    # We'll fail because of the bad 'file' response\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Slack requests pay close attention to the response to determine\n    # if things go well... this is not a good JSON response:\n    request.content = \"{\"\n    mock_request.reset_mock()\n    mock_request.return_value = request\n    mock_request.side_effect = None\n\n    # As a result, we'll fail to send our notification\n    assert obj.send(body=\"test\", attach=attach) is False\n\n    request.content = dumps({\n        \"ok\": False,\n        \"message\": \"We failed\",\n    })\n\n    # A response from Slack (even with a 200 response) still\n    # results in a failure:\n    assert obj.send(body=\"test\", attach=attach) is False\n\n    # Handle exceptions reading our attachment from disk (should it happen)\n    mock_request.side_effect = OSError(\"Attachment Error\")\n    mock_request.return_value = None\n\n    # We'll fail now because of an internal exception\n    assert obj.send(body=\"test\") is False\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_slack_webhook_mode(mock_request):\n    \"\"\"NotifySlack() Webhook Mode Tests.\"\"\"\n\n    # Prepare Mock\n    mock_request.return_value = requests.Request()\n    mock_request.return_value.status_code = requests.codes.ok\n    mock_request.return_value.content = b\"ok\"\n    mock_request.return_value.text = \"ok\"\n\n    # Initialize some generic (but valid) tokens\n    token_a = \"A\" * 9\n    token_b = \"B\" * 9\n    token_c = \"c\" * 24\n\n    # Support strings\n    channels = \"chan1,#chan2,+BAK4K23G5,@user,,,\"\n\n    obj = NotifySlack(\n        token_a=token_a, token_b=token_b, token_c=token_c, targets=channels\n    )\n    assert len(obj.channels) == 4\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Missing first Token\n    with pytest.raises(TypeError):\n        NotifySlack(\n            token_a=None, token_b=token_b, token_c=token_c, targets=channels\n        )\n\n    # Test include_image\n    obj = NotifySlack(\n        token_a=token_a,\n        token_b=token_b,\n        token_c=token_c,\n        targets=channels,\n        include_image=True,\n    )\n\n    # This call includes an image with it's payload:\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n\n@mock.patch(\"requests.request\")\n@mock.patch(\"requests.get\")\ndef test_plugin_slack_send_by_email(mock_get, mock_request):\n    \"\"\"NotifySlack() Send by Email Tests.\"\"\"\n\n    # Generate a (valid) bot token\n    token = \"xoxb-1234-1234-abc124\"\n\n    request = mock.Mock()\n    request.content = dumps(\n        {\"ok\": True, \"message\": \"\", \"user\": {\"id\": \"ABCD1234\"}}\n    )\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = request\n    mock_get.return_value = request\n\n    # Variation Initializations\n    obj = NotifySlack(access_token=token, targets=\"user@gmail.com\")\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # No calls made yet\n    assert mock_request.call_count == 0\n    assert mock_get.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # 2 calls were made, one to perform an email lookup, the second\n    # was the notification itself\n    assert mock_get.call_count == 1\n    assert mock_request.call_count == 1\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://slack.com/api/users.lookupByEmail\"\n    )\n    assert (\n        mock_request.call_args_list[0][0][1]\n        == \"https://slack.com/api/chat.postMessage\"\n    )\n\n    # Reset our mock object\n    mock_request.reset_mock()\n    mock_get.reset_mock()\n\n    # Prepare Mock\n    mock_request.return_value = request\n    mock_get.return_value = request\n\n    # Send our notification again (cached copy of user id associated with\n    # email is used)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert mock_get.call_count == 0\n    assert mock_request.call_count == 1\n    assert (\n        mock_request.call_args_list[0][0][1]\n        == \"https://slack.com/api/chat.postMessage\"\n    )\n\n    #\n    # Now test a case where we can't look up the valid email\n    #\n    request.content = dumps({\n        \"ok\": False,\n        \"message\": \"\",\n    })\n\n    # Reset our mock object\n    mock_request.reset_mock()\n    mock_get.reset_mock()\n\n    # Prepare Mock\n    mock_request.return_value = request\n    mock_get.return_value = request\n\n    # Variation Initializations\n    obj = NotifySlack(access_token=token, targets=\"user@gmail.com\")\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # No calls made yet\n    assert mock_request.call_count == 0\n    assert mock_get.call_count == 0\n\n    # Send our notification; it will fail because we failed to look up\n    # the user id\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n    # We would have failed to look up the email, therefore we wouldn't have\n    # even bothered to attempt to send the notification\n    assert mock_get.call_count == 1\n    assert mock_request.call_count == 0\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://slack.com/api/users.lookupByEmail\"\n    )\n\n    #\n    # Now test a case where we have a poorly formatted JSON response\n    #\n    request.content = \"}\"\n\n    # Reset our mock object\n    mock_request.reset_mock()\n    mock_get.reset_mock()\n\n    # Prepare Mock\n    mock_request.return_value = request\n    mock_get.return_value = request\n\n    # Variation Initializations\n    obj = NotifySlack(access_token=token, targets=\"user@gmail.com\")\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # No calls made yet\n    assert mock_request.call_count == 0\n    assert mock_get.call_count == 0\n\n    # Send our notification; it will fail because we failed to look up\n    # the user id\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n    # We would have failed to look up the email, therefore we wouldn't have\n    # even bothered to attempt to send the notification\n    assert mock_get.call_count == 1\n    assert mock_request.call_count == 0\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://slack.com/api/users.lookupByEmail\"\n    )\n\n    #\n    # Now test a case where we have a poorly formatted JSON response\n    #\n    request.content = \"}\"\n\n    # Reset our mock object\n    mock_request.reset_mock()\n    mock_get.reset_mock()\n\n    # Prepare Mock\n    mock_request.return_value = request\n    mock_get.return_value = request\n\n    # Variation Initializations\n    obj = NotifySlack(access_token=token, targets=\"user@gmail.com\")\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # No calls made yet\n    assert mock_request.call_count == 0\n    assert mock_get.call_count == 0\n\n    # Send our notification; it will fail because we failed to look up\n    # the user id\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n    # We would have failed to look up the email, therefore we wouldn't have\n    # even bothered to attempt to send the notification\n    assert mock_get.call_count == 1\n    assert mock_request.call_count == 0\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://slack.com/api/users.lookupByEmail\"\n    )\n\n    #\n    # Now test a case where we throw an exception trying to perform the lookup\n    #\n\n    request.content = dumps(\n        {\"ok\": True, \"message\": \"\", \"user\": {\"id\": \"ABCD1234\"}}\n    )\n    # Create an unauthorized response\n    request.status_code = requests.codes.ok\n\n    # Reset our mock object\n    mock_request.reset_mock()\n    mock_get.reset_mock()\n\n    # Prepare Mock\n    mock_request.return_value = request\n    mock_get.side_effect = requests.ConnectionError(\n        0, \"requests.ConnectionError() not handled\"\n    )\n\n    # Variation Initializations\n    obj = NotifySlack(access_token=token, targets=\"user@gmail.com\")\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # No calls made yet\n    assert mock_request.call_count == 0\n    assert mock_get.call_count == 0\n\n    # Send our notification; it will fail because we failed to look up\n    # the user id\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n    # We would have failed to look up the email, therefore we wouldn't have\n    # even bothered to attempt to send the notification\n    assert mock_get.call_count == 1\n    assert mock_request.call_count == 0\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://slack.com/api/users.lookupByEmail\"\n    )\n\n\n@mock.patch(\"requests.request\")\n@mock.patch(\"requests.get\")\ndef test_plugin_slack_markdown(mock_get, mock_request):\n    \"\"\"NotifySlack() Markdown tests.\"\"\"\n\n    request = mock.Mock()\n    request.content = b\"ok\"\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = request\n    mock_get.return_value = request\n\n    # Variation Initializations\n    aobj = Apprise()\n    assert aobj.add(\n        \"slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#channel\"\n    )\n\n    body = cleandoc(\"\"\"\n    Here is a <https://slack.com|Slack Link> we want to support as part of it's\n    markdown.\n\n    This one has arguments we want to preserve:\n       <https://slack.com?arg=val&arg2=val2|Slack Link>.\n    We also want to be able to support <https://slack.com> links without the\n    description.\n\n    Channel Testing\n    <!channelA>\n    <!channelA|Description>\n\n    User ID Testing\n    <@U1ZQL9N3Y>\n    <@U1ZQL9N3Y|heheh>\n    \"\"\")\n\n    # Send our notification\n    assert aobj.notify(body=body, title=\"title\", notify_type=NotifyType.INFO)\n\n    # We would have failed to look up the email, therefore we wouldn't have\n    # even bothered to attempt to send the notification\n    assert mock_get.call_count == 0\n    assert mock_request.call_count == 1\n    assert (\n        mock_request.call_args_list[0][0][1]\n        == \"https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/\"\n        \"TIiajkdnlazkcOXrIdevi7FQ\"\n    )\n\n    data = loads(mock_request.call_args_list[0][1][\"data\"])\n    assert (\n        data[\"attachments\"][0][\"text\"]\n        == \"Here is a <https://slack.com|Slack Link> we want to support as\"\n        \" part \"\n        \"of it's\\nmarkdown.\\n\\nThis one has arguments we want to preserve:\"\n        \"\\n   <https://slack.com?arg=val&arg2=val2|Slack Link>.\\n\"\n        \"We also want to be able to support <https://slack.com> \"\n        \"links without the\\ndescription.\"\n        \"\\n\\nChannel Testing\\n<!channelA>\\n<!channelA|Description>\\n\\n\"\n        \"User ID Testing\\n<@U1ZQL9N3Y>\\n<@U1ZQL9N3Y|heheh>\"\n    )\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_slack_single_thread_reply(mock_request):\n    \"\"\"NotifySlack() Send Notification as a Reply.\"\"\"\n\n    # Generate a (valid) bot token\n    token = \"xoxb-1234-1234-abc124\"\n    thread_id = 100\n    request = mock.Mock()\n    request.content = dumps(\n        {\"ok\": True, \"message\": \"\", \"user\": {\"id\": \"ABCD1234\"}}\n    )\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = request\n\n    # Variation Initializations\n    obj = NotifySlack(access_token=token, targets=[f\"#general:{thread_id}\"])\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # No calls made yet\n    assert mock_request.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Post was made\n    assert mock_request.call_count == 1\n    assert (\n        mock_request.call_args_list[0][0][1]\n        == \"https://slack.com/api/chat.postMessage\"\n    )\n    assert loads(mock_request.call_args_list[0][1][\"data\"]).get(\n        \"thread_ts\"\n    ) == str(thread_id)\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_slack_multiple_thread_reply(mock_request):\n    \"\"\"NotifySlack() Send Notification to multiple channels as Reply.\"\"\"\n\n    # Generate a (valid) bot token\n    token = \"xoxb-1234-1234-abc124\"\n    thread_id_1, thread_id_2 = 100, 200\n    request = mock.Mock()\n    request.content = dumps(\n        {\"ok\": True, \"message\": \"\", \"user\": {\"id\": \"ABCD1234\"}}\n    )\n    request.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_request.return_value = request\n\n    # Variation Initializations\n    obj = NotifySlack(\n        access_token=token,\n        targets=[f\"#general:{thread_id_1}\", f\"#other:{thread_id_2}\"],\n    )\n    assert isinstance(obj, NotifySlack) is True\n    assert isinstance(obj.url(), str) is True\n\n    # No calls made yet\n    assert mock_request.call_count == 0\n\n    # Send our notification\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Post was made\n    assert mock_request.call_count == 2\n    assert (\n        mock_request.call_args_list[0][0][1]\n        == \"https://slack.com/api/chat.postMessage\"\n    )\n    assert loads(mock_request.call_args_list[0][1][\"data\"]).get(\n        \"thread_ts\"\n    ) == str(thread_id_1)\n    assert loads(mock_request.call_args_list[1][1][\"data\"]).get(\n        \"thread_ts\"\n    ) == str(thread_id_2)\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_slack_file_upload_success(mock_request):\n    \"\"\"Test Slack BOT attachment upload success path.\"\"\"\n\n    token = \"xoxb-1234-1234-abc124\"\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n\n    # Simulate all successful Slack API responses\n    mock_request.side_effect = [\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"channel\": \"C123456\",\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"upload_url\": \"https://files.slack.com/upload/v1/ABC123\",\n                \"file_id\": \"F123ABC456\",\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n        mock.Mock(**{\n            \"content\": b\"OK - 123\",\n            \"status_code\": requests.codes.ok,\n        }),\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"files\": [{\"id\": \"F123ABC456\", \"title\": \"apprise-test\"}],\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n    ]\n\n    obj = NotifySlack(access_token=token, targets=[\"#general\"])\n    assert obj.notify(\n        body=\"Success path test\",\n        title=\"Slack Upload OK\",\n        notify_type=NotifyType.INFO,\n        attach=attach,\n    ) is True\n\n\n@mock.patch(\"requests.request\")\ndef test_plugin_slack_file_upload_fails_missing_files(mock_request):\n    \"\"\"Test that file upload fails when 'files' is missing or empty.\"\"\"\n\n    token = \"xoxb-1234-1234-abc124\"\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n\n    # Mock sequence:\n    # 1. chat.postMessage returns valid channel\n    # 2. files.getUploadURLExternal returns file_id and upload_url\n    # 3. Upload returns 'OK'\n    # 4. files.completeUploadExternal returns missing/empty 'files'\n\n    mock_request.side_effect = [\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"channel\": \"C555555\",\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"upload_url\": \"https://files.slack.com/upload/v1/X99999\",\n                \"file_id\": \"F999XYZ888\",\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n        mock.Mock(**{\n            \"content\": b\"OK - 2048\",\n            \"status_code\": requests.codes.ok,\n        }),\n        # <== This response will trigger the error condition\n        mock.Mock(**{\n            \"content\": dumps({\n                \"ok\": True,\n                \"files\": [],\n            }),\n            \"status_code\": requests.codes.ok,\n        }),\n    ]\n\n    obj = NotifySlack(access_token=token, targets=[\"#fail-channel\"])\n    result = obj.notify(\n        body=\"This should trigger a failed file upload\",\n        title=\"Trigger failure\",\n        notify_type=NotifyType.INFO,\n        attach=attach,\n    )\n\n    assert result is False\n"
  },
  {
    "path": "tests/test_plugin_smpp.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom contextlib import suppress\nimport logging\nimport sys\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.smpp import NotifySMPP\n\nwith suppress(ImportError):\n    import smpplib\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"smpp://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp:///\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://user@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://user:pass/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://user:pass@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://user@hostname\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://user:pass@host:/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://user:pass@host:2775/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smpp://user:pass@host:2775/{}/{}\".format(\"1\" * 10, \"a\" * 32),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifySMPP,\n            # We have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"smpp://user:pass@host:2775/{}\".format(\"1\" * 10),\n        {\n            # everything valid\n            \"instance\": NotifySMPP,\n            # We have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"smpp://user:pass@host/{}/{}\".format(\"1\" * 10, \"1\" * 10),\n        {\n            \"instance\": NotifySMPP,\n        },\n    ),\n    (\n        \"smpps://_?&from={}&to={},{}&user=user&password=pw\".format(\n            \"1\" * 10, \"1\" * 10, \"1\" * 10\n        ),\n        {\n            # use get args to accomplish the same thing\n            \"instance\": NotifySMPP,\n        },\n    ),\n)\n\n\n@pytest.mark.skipif(\n    \"smpplib\" in sys.modules, reason=\"Requires that smpplib NOT be installed\"\n)\ndef test_plugin_smpplib_import_error():\n    \"\"\"NotifySMPP() smpplib loading failure.\"\"\"\n\n    # Attempt to instantiate our object\n    obj = Apprise.instantiate(\n        \"smpp://user:pass@host/{}/{}\".format(\"1\" * 10, \"1\" * 10)\n    )\n\n    # It's not possible because our cryptography depedancy is missing\n    assert obj is None\n\n\n@pytest.mark.skipif(\"smpplib\" not in sys.modules, reason=\"Requires smpplib\")\ndef test_plugin_smpp_urls():\n    \"\"\"NotifySMPP() Apprise URLs.\"\"\"\n    # mock nested inside of outside function to avoid failing\n    # when smpplib is unavailable\n    with mock.patch(\"smpplib.client.Client\") as mock_client_class:\n        mock_client_instance = mock.Mock()\n        mock_client_class.return_value = mock_client_instance\n\n        # Raise exception on connect\n        mock_client_instance.connect.return_value = True\n        mock_client_instance.bind_transmitter.return_value = True\n        mock_client_instance.send_message.return_value = True\n\n        # Run our general tests\n        AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@pytest.mark.skipif(\"smpplib\" not in sys.modules, reason=\"Requires smpplib\")\ndef test_plugin_smpp_edge_case():\n    \"\"\"NotifySMPP() Apprise Edge Case.\"\"\"\n\n    # mock nested inside of outside function to avoid failing\n    # when smpplib is unavailable\n    with mock.patch(\"smpplib.client.Client\") as mock_client_class:\n        mock_client_instance = mock.Mock()\n        mock_client_class.return_value = mock_client_instance\n\n        # Raise exception on connect\n        mock_client_instance.connect.side_effect = (\n            smpplib.exceptions.ConnectionError\n        )\n        mock_client_instance.bind_transmitter.return_value = True\n        mock_client_instance.send_message.return_value = True\n\n        # Instantiate our object\n        obj = Apprise.instantiate(\n            \"smpp://user:pass@host/{}/{}\".format(\"1\" * 10, \"1\" * 10)\n        )\n\n        # Well fail to establish a connection\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is False\n        )\n\n        # Raise exception on connect\n        mock_client_instance.connect.side_effect = None\n        mock_client_instance.bind_transmitter.return_value = True\n        mock_client_instance.send_message.side_effect = (\n            smpplib.exceptions.ConnectionError\n        )\n\n        # Well fail to deliver our message\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is False\n        )\n"
  },
  {
    "path": "tests/test_plugin_sms_manager.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.smsmanager import NotifySMSManager\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"smsmgr://\",\n        {\n            # Instantiated but no auth, so no otification can happen\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smsmgr://:@/\",\n        {\n            # invalid auth\n            \"instance\": TypeError\n        },\n    ),\n    (\n        \"smsmgr://{}@{}\".format(\"b\" * 10, \"3\" * 5),\n        {\n            # invalid nubmer provided\n            \"instance\": NotifySMSManager,\n            # Expected notify() response because we have no one to notify\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"smsmgr://{}@123/{}/abcd/+{}\".format(\"z\" * 10, \"3\" * 11, \"4\" * 11),\n        {\n            # includes a few invalid bits of info\n            \"instance\": NotifySMSManager,\n            \"privacy_url\": \"smsmgr://z...z@33333333333/+44444444444\",\n        },\n    ),\n    (\n        \"smsmgr://{}@{}?batch=y\".format(\"b\" * 5, \"4\" * 11),\n        {\n            \"instance\": NotifySMSManager,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smsmgr://b...b@44444444444\",\n        },\n    ),\n    # Test gateway group\n    (\n        \"smsmgr://{}@{}?gateway=low\".format(\"a\" * 10, \"1\" * 11),\n        {\n            \"instance\": NotifySMSManager,\n        },\n    ),\n    (\n        \"smsmgr://{}@{}?gateway=invalid\".format(\"a\" * 10, \"1\" * 11),\n        {\n            # invalid gatewwway\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smsmgr://{}?key={}&from=user\".format(\"1\" * 11, \"a\" * 10),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifySMSManager,\n        },\n    ),\n    (\n        \"smsmgr://_?to={},{}&key={}&sender={}\".format(\n            \"1\" * 11, \"2\" * 11, \"b\" * 10, \"5\" * 13\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifySMSManager,\n        },\n    ),\n    (\n        \"smsmgr://{}@{}\".format(\"a\" * 10, \"1\" * 11),\n        {\n            \"instance\": NotifySMSManager,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"smsmgr://{}@{}\".format(\"a\" * 10, \"1\" * 11),\n        {\n            \"instance\": NotifySMSManager,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_smsmgr_urls():\n    \"\"\"NotifyTemplate() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\ndef test_plugin_smsmgr_edge_cases(mock_get):\n    \"\"\"NotifySMSManager() Edge Cases.\"\"\"\n\n    # Initialize some generic (but valid) tokens\n    apikey = \"my-api-key\"\n    targets = [\n        \"+1(555) 123-1234\",\n        \"1555 5555555\",\n        # A garbage entry\n        \"12\",\n        # NOw a valid one because a group was implicit\n        \"@12\",\n    ]\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_get.return_value = response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\n        \"smsmgr://{}@{}?batch=n\".format(apikey, \"/\".join(targets))\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # We know there are 2 (valid) targets\n    assert len(obj) == 2\n\n    # Test our call count\n    assert mock_get.call_count == 2\n\n    # Test\n    details = mock_get.call_args_list[0]\n    payload = details[1][\"params\"]\n    assert payload[\"apikey\"] == apikey\n    assert payload[\"gateway\"] == \"high\"\n    assert payload[\"number\"] == \"+15551231234\"\n    assert payload[\"message\"] == \"title\\r\\nbody\"\n\n    details = mock_get.call_args_list[1]\n    payload = details[1][\"params\"]\n    assert payload[\"apikey\"] == apikey\n    assert payload[\"gateway\"] == \"high\"\n    assert payload[\"number\"] == \"15555555555\"\n    assert payload[\"message\"] == \"title\\r\\nbody\"\n\n    # Verify our URL looks good\n    assert obj.url().startswith(\n        \"smsmgr://{}@{}\".format(\n            apikey, \"/\".join([\"+15551231234\", \"15555555555\"])\n        )\n    )\n\n    assert \"batch=no\" in obj.url()\n\n    # Reset our mock object\n    mock_get.reset_mock()\n\n    # With our batch in place, our calculations are different\n    obj = Apprise.instantiate(\n        \"smsmgr://{}@{}?batch=y\".format(apikey, \"/\".join(targets))\n    )\n\n    # 2 phones were loaded but counted as 1 due to batch flag\n    assert len(obj) == 1\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Test our call count (batched into 1)\n    assert mock_get.call_count == 1\n\n    details = mock_get.call_args_list[0]\n    payload = details[1][\"params\"]\n    assert payload[\"apikey\"] == apikey\n    assert payload[\"gateway\"] == \"high\"\n    assert payload[\"number\"] == \"+15551231234;15555555555\"\n    assert payload[\"message\"] == \"title\\r\\nbody\"\n"
  },
  {
    "path": "tests/test_plugin_smseagle.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.smseagle import NotifySMSEagle\n\nlogging.disable(logging.CRITICAL)\n\nSMSEAGLE_GOOD_RESPONSE = dumps(\n    {\"result\": {\"message_id\": \"748\", \"status\": \"ok\"}}\n)\n\nSMSEAGLE_BAD_RESPONSE = dumps(\n    {\n        \"result\": {\n            \"error_text\": \"Wrong parameters\",\n            \"status\": \"error\",\n        }\n    }\n)\n\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"smseagle://\",\n        {\n            # No host specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smseagle://:@/\",\n        {\n            # invalid host\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smseagle://localhost\",\n        {\n            # Just a host provided (no access token)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smseagle://%20@localhost\",\n        {\n            # invalid token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smseagle://token@localhost/123/\",\n        {\n            # invalid 'to' phone number\n            \"instance\": NotifySMSEagle,\n            # Notify will fail because it couldn't send to anyone\n            \"response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagle://****@localhost/@123\",\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://tokenb@localhost/%20/%20/\",\n        {\n            # invalid 'to' phone number\n            \"instance\": NotifySMSEagle,\n            # Notify will fail because it couldn't send to anyone\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagle://****@localhost/\",\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8080/{}/\".format(\"1\" * 11),\n        {\n            # one phone number will notify ourselves\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://localhost:8080/{}/?token=abc1234\".format(\"1\" * 11),\n        {\n            # pass our token in as an argument\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n            \"force_debug\": True,\n        },\n    ),\n    # Set priority\n    (\n        \"smseagle://token@localhost/@user/?priority=high\",\n        {\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    # Support integer value too\n    (\n        \"smseagle://token@localhost/@user/?priority=1\",\n        {\n            \"instance\": NotifySMSEagle,\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    # Invalid priority\n    (\n        \"smseagle://token@localhost/@user/?priority=invalid\",\n        {\n            # Invalid Priority\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid priority\n    (\n        \"smseagle://token@localhost/@user/?priority=25\",\n        {\n            # Invalid Priority\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8082/#abcd/\",\n        {\n            # a valid group\n            \"instance\": NotifySMSEagle,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagle://****@localhost:8082/#abcd\",\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8082/@abcd/\",\n        {\n            # a valid contact\n            \"instance\": NotifySMSEagle,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagle://****@localhost:8082/@abcd\",\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagles://token@localhost:8081/contact/\",\n        {\n            # another valid group (without @ symbol)\n            \"instance\": NotifySMSEagle,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagles://****@localhost:8081/@contact\",\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8082/@/#/,/\",\n        {\n            # Test case where we provide bad data\n            \"instance\": NotifySMSEagle,\n            # Our failed response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8083/@user/\",\n        {\n            # Test case where we get a bad response\n            \"instance\": NotifySMSEagle,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagle://****@localhost:8083/@user\",\n            # Our failed response\n            \"requests_response_text\": SMSEAGLE_BAD_RESPONSE,\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8084/@user/\",\n        {\n            # Test case where we get a bad response\n            \"instance\": NotifySMSEagle,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagle://****@localhost:8084/@user\",\n            # Our failed response\n            \"requests_response_text\": None,\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8085/@user/\",\n        {\n            # Test case where we get a bad response\n            \"instance\": NotifySMSEagle,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"smseagle://****@localhost:8085/@user\",\n            # Our failed response (bad json)\n            \"requests_response_text\": \"{\",\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8086/?to={},{}\".format(\"2\" * 11, \"3\" * 11),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8087/?to={},{},{}\".format(\n            \"2\" * 11, \"3\" * 11, \"5\" * 3\n        ),\n        {\n            # 2 good targets and one invalid one\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://token@localhost:8088/{}/{}/\".format(\"2\" * 11, \"3\" * 11),\n        {\n            # If we have from= specified, then all elements take on the\n            # to= value\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagles://token@localhost/{}\".format(\"3\" * 11),\n        {\n            # use get args to acomplish the same thing (use source instead of\n            # from)\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagles://token@localhost/{}/{}?batch=True\".format(\n            \"3\" * 11, \"4\" * 11\n        ),\n        {\n            # test batch mode\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagles://token@localhost/{}/?flash=yes\".format(\"3\" * 11),\n        {\n            # test flash mode\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagles://token@localhost/{}/?test=yes\".format(\"3\" * 11),\n        {\n            # test mode\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagles://token@localhost/{}/{}?status=True\".format(\n            \"3\" * 11, \"4\" * 11\n        ),\n        {\n            # test status switch\n            \"instance\": NotifySMSEagle,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://token@localhost/{}\".format(\"4\" * 11),\n        {\n            \"instance\": NotifySMSEagle,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            # Our response expected server response\n            \"requests_response_text\": SMSEAGLE_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"smseagle://token@localhost/{}\".format(\"4\" * 11),\n        {\n            \"instance\": NotifySMSEagle,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_smseagle_urls():\n    \"\"\"NotifySMSEagle() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_smseagle_edge_cases(mock_post):\n    \"\"\"NotifySMSEagle() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n    response.content = SMSEAGLE_GOOD_RESPONSE\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    target = \"+1 (555) 987-5432\"\n    body = \"test body\"\n    title = \"My Title\"\n\n    aobj = Apprise()\n    assert aobj.add(f\"smseagles://token@localhost:231/{target}\")\n    assert len(aobj) == 1\n    assert aobj.notify(title=title, body=body)\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"https://localhost:231/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"params\"][\"message\"] == \"My Title\\r\\ntest body\"\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    aobj = Apprise()\n    assert aobj.add(f\"smseagles://token@localhost:231/{target}?status=Yes\")\n    assert len(aobj) == 1\n    assert aobj.notify(title=title, body=body)\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"https://localhost:231/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    # Status flag is set\n    assert payload[\"params\"][\"message\"] == \"[i] My Title\\r\\ntest body\"\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_smseagle_result_set(mock_post):\n    \"\"\"NotifySMSEagle() Result Sets.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n    response.content = SMSEAGLE_GOOD_RESPONSE\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    body = \"test body\"\n    title = \"My Title\"\n\n    aobj = Apprise()\n    aobj.add(\n        \"smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/\"\n        \"12514444444?batch=yes\"\n    )\n    # In a batch mode we can shove them all into 1 call\n    assert len(aobj[0]) == 1\n\n    assert aobj.notify(title=title, body=body)\n\n    # If a batch, there is only 1 post\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert \"method\" in payload\n    assert payload[\"method\"] == \"sms.send_sms\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"to\" in params\n    assert len(params[\"to\"].split(\",\")) == 3\n\n    assert \"+12512222222\" in params[\"to\"].split(\",\")\n    assert \"+12513333333\" in params[\"to\"].split(\",\")\n    # The + is not appended\n    assert \"12514444444\" in params[\"to\"].split(\",\")\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"My Title\\r\\ntest body\"\n\n    # Reset our test and turn batch mode off\n    mock_post.reset_mock()\n\n    aobj = Apprise()\n    aobj.add(\n        \"smseagle://token@10.0.0.112:8080/#group/Contact/123456789?batch=no\"\n    )\n    assert len(aobj[0]) == 3\n\n    assert aobj.notify(title=title, body=body)\n\n    # If batch is off then there is a post per entry\n    assert mock_post.call_count == 3\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_sms\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"to\" in params\n    assert len(params[\"to\"].split(\",\")) == 1\n    assert \"123456789\" in params[\"to\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"My Title\\r\\ntest body\"\n\n    details = mock_post.call_args_list[1]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_togroup\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"groupname\" in params\n    assert len(params[\"groupname\"].split(\",\")) == 1\n    assert \"group\" in params[\"groupname\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"My Title\\r\\ntest body\"\n\n    details = mock_post.call_args_list[2]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_tocontact\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"contactname\" in params\n    assert len(params[\"contactname\"].split(\",\")) == 1\n    assert \"Contact\" in params[\"contactname\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"My Title\\r\\ntest body\"\n\n    mock_post.reset_mock()\n\n    # Test groups and contact names\n    aobj = Apprise()\n    aobj.add(\n        \"smseagle://token@10.0.0.112:8080/513333333/#group1/@contact1/\"\n        \"contact2/12514444444?batch=yes\"\n    )\n\n    # contacts and numbers can be combined and is calculated in batch response\n    assert len(aobj[0]) == 3\n    assert aobj.notify(title=title, body=body)\n\n    # There is a unique post to each (group, contact x2, and phone x2)\n    # The key is the contacts were grouped here in 1 post each\n    assert mock_post.call_count == 3\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_sms\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"to\" in params\n    assert len(params[\"to\"].split(\",\")) == 2\n    assert \"513333333\" in params[\"to\"].split(\",\")\n    assert \"12514444444\" in params[\"to\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"My Title\\r\\ntest body\"\n\n    details = mock_post.call_args_list[1]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_togroup\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"groupname\" in params\n    assert len(params[\"groupname\"].split(\",\")) == 1\n    assert \"group1\" in params[\"groupname\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"My Title\\r\\ntest body\"\n\n    details = mock_post.call_args_list[2]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_tocontact\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"contactname\" in params\n    assert len(params[\"contactname\"].split(\",\")) == 2\n    assert \"contact1\" in params[\"contactname\"].split(\",\")\n    assert \"contact2\" in params[\"contactname\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"My Title\\r\\ntest body\"\n\n    # Validate our information is also placed back into the assembled URL\n    assert \"/@contact1\" in aobj[0].url()\n    assert \"/@contact2\" in aobj[0].url()\n    assert \"/#group1\" in aobj[0].url()\n    assert \"/513333333\" in aobj[0].url()\n    assert \"/12514444444\" in aobj[0].url()\n\n\n@mock.patch(\"requests.post\")\ndef test_notify_smseagle_plugin_result_list(mock_post):\n    \"\"\"NotifySMSEagle() Result List Response.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    # We want to test the case where the `result` set returned is a list\n    okay_response.content = dumps(\n        {\"result\": [{\"message_id\": \"748\", \"status\": \"ok\"}]}\n    )\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    obj = Apprise.instantiate(\"smseagle://token@127.0.0.1/12222222/\")\n    assert isinstance(obj, NotifySMSEagle)\n\n    # We should successfully handle the list\n    assert obj.notify(\"test\") is True\n\n    # However if one of the elements in the list is bad\n    okay_response.content = dumps({\n        \"result\": [\n            {\"message_id\": \"748\", \"status\": \"ok\"},\n            {\"message_id\": \"749\", \"status\": \"error\"},\n        ]\n    })\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # We should now fail\n    assert obj.notify(\"test\") is False\n\n\n@mock.patch(\"requests.post\")\ndef test_notify_smseagle_plugin_attachments(mock_post):\n    \"\"\"NotifySMSEagle() Attachments.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = SMSEAGLE_GOOD_RESPONSE\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    obj = Apprise.instantiate(\n        \"smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/\"\n        \"12514444444?batch=no\"\n    )\n    assert isinstance(obj, NotifySMSEagle)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n\n    # Return our good configuration\n    mock_post.side_effect = None\n    mock_post.return_value = okay_response\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # test the handling of our batch modes\n    obj = Apprise.instantiate(\n        \"smseagle://token@10.0.0.112:8080/+12512222222/+12513333333/\"\n        \"12514444444?batch=yes\"\n    )\n    assert isinstance(obj, NotifySMSEagle)\n\n    # Now send an attachment normally without issues\n    mock_post.reset_mock()\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Verify we posted upstream\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_sms\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"to\" in params\n    assert len(params[\"to\"].split(\",\")) == 3\n    assert \"+12512222222\" in params[\"to\"].split(\",\")\n    assert \"+12513333333\" in params[\"to\"].split(\",\")\n    assert \"12514444444\" in params[\"to\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"mms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"title\\r\\nbody\"\n\n    # Verify our attachments are in place\n    assert \"attachments\" in params\n    assert isinstance(params[\"attachments\"], list)\n    assert len(params[\"attachments\"]) == 3\n    for entry in params[\"attachments\"]:\n        assert \"content\" in entry\n        assert \"content_type\" in entry\n        assert entry.get(\"content_type\").startswith(\"image/\")\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n    # test the handling of our batch modes\n    obj = Apprise.instantiate(\"smseagle://token@10.0.0.112:8080/513333333/\")\n    assert isinstance(obj, NotifySMSEagle)\n\n    # Unsupported (non image types are not sent)\n    attach = os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Verify we still posted upstream\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://10.0.0.112:8080/jsonrpc/sms\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"method\"] == \"sms.send_sms\"\n\n    assert \"params\" in payload\n    assert isinstance(payload[\"params\"], dict)\n    params = payload[\"params\"]\n    assert \"to\" in params\n    assert len(params[\"to\"].split(\",\")) == 1\n    assert \"513333333\" in params[\"to\"].split(\",\")\n\n    assert params.get(\"message_type\") == \"sms\"\n    assert params.get(\"responsetype\") == \"extended\"\n    assert params.get(\"access_token\") == \"token\"\n    assert params.get(\"highpriority\") == 0\n    assert params.get(\"flash\") == 0\n    assert params.get(\"test\") == 0\n    assert params.get(\"unicode\") == 1\n    assert params.get(\"message\") == \"title\\r\\nbody\"\n\n    # No attachments were added\n    assert \"attachments\" not in params\n"
  },
  {
    "path": "tests/test_plugin_smtp2go.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.smtp2go import NotifySMTP2Go\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"smtp2go://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"smtp2go://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No Token specified\n    (\n        \"smtp2go://user@localhost.localdomain\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Token is valid, but no user name specified\n    (\n        \"smtp2go://localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid from email address\n    (\n        'smtp2go://\"@localhost.localdomain/{}-{}-{}'.format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No To email address, but everything else is valid\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n        },\n    ),\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}?format=markdown\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n        },\n    ),\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}?format=html\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n        },\n    ),\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}?format=text\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n        },\n    ),\n    # headers\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}\"\n        \"?+X-Customer-Campaign-ID=Apprise\".format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\n            \"instance\": NotifySMTP2Go,\n            \"force_debug\": True,\n        },\n    ),\n    # bcc and cc\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}\"\n        \"?bcc=user@example.com&cc=user2@example.com\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n        },\n    ),\n    # One To Email address\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}/test@example.com\"\n        .format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\n            \"instance\": NotifySMTP2Go,\n        },\n    ),\n    (\n        \"smtp2go://user@localhost.localdomain/\"\n        \"{}-{}-{}?to=test@example.com\".format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\"instance\": NotifySMTP2Go},\n    ),\n    # One To Email address, a from name specified too\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}/\"\n        'test@example.com?name=\"Frodo\"'.format(\"a\" * 32, \"b\" * 8, \"c\" * 8),\n        {\"instance\": NotifySMTP2Go},\n    ),\n    # Invalid 'To' Email address\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}/invalid\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n            # Expected notify() response\n            \"notify_response\": False,\n        },\n    ),\n    # Multiple 'To', 'Cc', and 'Bcc' addresses (with invalid ones)\n    (\n        \"smtp2go://user@example.com/{}-{}-{}/{}?bcc={}&cc={}\".format(\n            \"a\" * 32,\n            \"b\" * 8,\n            \"c\" * 8,\n            \"/\".join(\n                (\"user1@example.com\", \"invalid\", \"User2:user2@example.com\")\n            ),\n            \",\".join((\"user3@example.com\", \"i@v\", \"User1:user1@example.com\")),\n            \",\".join((\"user4@example.com\", \"g@r@b\", \"Da:user5@example.com\")),\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n        },\n    ),\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"smtp2go://user@localhost.localdomain/{}-{}-{}\".format(\n            \"a\" * 32, \"b\" * 8, \"c\" * 8\n        ),\n        {\n            \"instance\": NotifySMTP2Go,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_smtp2go_urls():\n    \"\"\"NotifySMTP2Go() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_smtp2go_attachments(mock_post):\n    \"\"\"NotifySMTP2Go() Attachments.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # API Key\n    apikey = \"abc123\"\n\n    obj = Apprise.instantiate(f\"smtp2go://user@localhost.localdomain/{apikey}\")\n    assert isinstance(obj, NotifySMTP2Go)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    mock_post.return_value = None\n    mock_post.side_effect = OSError()\n    # We can't send the message if we can't read the attachment\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Test Valid Attachment (load 3)\n    path = (\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n    )\n    attach = AppriseAttachment(path)\n\n    # Return our good configuration\n    mock_post.side_effect = None\n    mock_post.return_value = okay_response\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        # We can't send the message we can't open the attachment for reading\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    # test the handling of our batch modes\n    obj = Apprise.instantiate(\n        f\"smtp2go://no-reply@example.com/{apikey}/\"\n        \"user1@example.com/user2@example.com?batch=yes\"\n    )\n    assert isinstance(obj, NotifySMTP2Go)\n\n    # objects will be combined into a single post in batch mode\n    assert len(obj) == 1\n\n    # Force our batch to break into separate messages\n    obj.default_batch_size = 1\n\n    # We'll send 2 messages now\n    assert len(obj) == 2\n\n    mock_post.reset_mock()\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_post.call_count == 2\n\n    # single batch\n    mock_post.reset_mock()\n    # We'll send 1 message\n    obj.default_batch_size = 2\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_post.call_count == 1\n"
  },
  {
    "path": "tests/test_plugin_sns.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.sns import NotifySNS\n\nlogging.disable(logging.CRITICAL)\n\n\nTEST_ACCESS_KEY_ID = \"AHIAJGNT76XIMXDBIJYA\"\nTEST_ACCESS_KEY_SECRET = \"bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9\"\nTEST_REGION = \"us-east-2\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"sns://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sns://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sns://T1JJ3T3L2\",\n        {\n            # Just Token 1 provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sns://T1JJ3TD4JD/TIiajkdnlazk7FQ/\",\n        {\n            # Missing a region\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/12223334444\",\n        {\n            # we have a valid URL and one number to text\n            \"instance\": NotifySNS,\n        },\n    ),\n    (\n        (\n            \"sns://?access=T1JJ3T3L2&secret=A1BRTD4JD/TIiajkdnlazkcevi7FQ\"\n            \"&region=us-west-2&to=12223334444\"\n        ),\n        {\n            # Initialize using get parameters instead\n            \"instance\": NotifySNS,\n        },\n    ),\n    (\n        \"sns://T1JJ3TD4JD/TIiajkdnlazk7FQ/us-west-2/12223334444/12223334445\",\n        {\n            # Multi SNS Suppport\n            \"instance\": NotifySNS,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"sns://T...D/****/us-west-2\",\n        },\n    ),\n    (\n        (\n            \"sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/us-east-1\"\n            \"?to=12223334444\"\n        ),\n        {\n            # Missing a topic and/or phone No\n            \"instance\": NotifySNS,\n        },\n    ),\n    (\n        \"sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/12223334444\",\n        {\n            \"instance\": NotifySNS,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcevi7FQ/us-west-2/15556667777\",\n        {\n            \"instance\": NotifySNS,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_sns_urls():\n    \"\"\"NotifySNS() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n# We initialize a post object just incase a test fails below\n# we don't want it sending any notifications upstream\n@mock.patch(\"requests.post\")\ndef test_plugin_sns_edge_cases(mock_post):\n    \"\"\"NotifySNS() Edge Cases.\"\"\"\n    target = \"+1800555999\"\n    # Initializes the plugin with a valid access, but invalid access key\n    with pytest.raises(TypeError):\n        # No access_key_id specified\n        NotifySNS(\n            access_key_id=None,\n            secret_access_key=TEST_ACCESS_KEY_SECRET,\n            region_name=TEST_REGION,\n            targets=target,\n        )\n\n    with pytest.raises(TypeError):\n        # No secret_access_key specified\n        NotifySNS(\n            access_key_id=TEST_ACCESS_KEY_ID,\n            secret_access_key=None,\n            region_name=TEST_REGION,\n            targets=target,\n        )\n\n    with pytest.raises(TypeError):\n        # No region_name specified\n        NotifySNS(\n            access_key_id=TEST_ACCESS_KEY_ID,\n            secret_access_key=TEST_ACCESS_KEY_SECRET,\n            region_name=None,\n            targets=target,\n        )\n\n    # No recipients\n    obj = NotifySNS(\n        access_key_id=TEST_ACCESS_KEY_ID,\n        secret_access_key=TEST_ACCESS_KEY_SECRET,\n        region_name=TEST_REGION,\n        targets=None,\n    )\n\n    # The object initializes properly but would not be able to send anything\n    assert obj.notify(body=\"test\", title=\"test\") is False\n\n    # The phone number is invalid, and without it, there is nothing\n    # to notify\n    obj = NotifySNS(\n        access_key_id=TEST_ACCESS_KEY_ID,\n        secret_access_key=TEST_ACCESS_KEY_SECRET,\n        region_name=TEST_REGION,\n        targets=\"+1809\",\n    )\n\n    # The object initializes properly but would not be able to send anything\n    assert obj.notify(body=\"test\", title=\"test\") is False\n\n    # The phone number is invalid, and without it, there is nothing\n    # to notify; we\n    obj = NotifySNS(\n        access_key_id=TEST_ACCESS_KEY_ID,\n        secret_access_key=TEST_ACCESS_KEY_SECRET,\n        region_name=TEST_REGION,\n        targets=\"#(invalid-topic-because-of-the-brackets)\",\n    )\n\n    # The object initializes properly but would not be able to send anything\n    assert obj.notify(body=\"test\", title=\"test\") is False\n\n\ndef test_plugin_sns_url_parsing():\n    \"\"\"NotifySNS() URL Parsing.\"\"\"\n\n    # No recipients\n    results = NotifySNS.parse_url(\n        f\"sns://{TEST_ACCESS_KEY_ID}/{TEST_ACCESS_KEY_SECRET}/{TEST_REGION}/\"\n    )\n\n    # Confirm that there were no recipients found\n    assert len(results[\"targets\"]) == 0\n    assert \"region_name\" in results\n    assert results[\"region_name\"] == TEST_REGION\n    assert \"access_key_id\" in results\n    assert results[\"access_key_id\"] == TEST_ACCESS_KEY_ID\n    assert \"secret_access_key\" in results\n    assert results[\"secret_access_key\"] == TEST_ACCESS_KEY_SECRET\n\n    target = \"+18001234567\"\n    topic = \"MyTopic\"\n\n    # Detect recipients\n    results = NotifySNS.parse_url(\n        f\"sns://{TEST_ACCESS_KEY_ID}/\"\n        f\"{TEST_ACCESS_KEY_SECRET}/\"\n        f\"{TEST_REGION.upper()}/\"\n        f\"{target}/\"\n        f\"{topic}/\"\n    )\n\n    # Confirm that our recipients were found\n    assert len(results[\"targets\"]) == 2\n    assert target in results[\"targets\"]\n    assert topic in results[\"targets\"]\n    assert \"region_name\" in results\n    assert results[\"region_name\"] == TEST_REGION\n    assert \"access_key_id\" in results\n    assert results[\"access_key_id\"] == TEST_ACCESS_KEY_ID\n    assert \"secret_access_key\" in results\n    assert results[\"secret_access_key\"] == TEST_ACCESS_KEY_SECRET\n\n\ndef test_plugin_sns_object_parsing():\n    \"\"\"NotifySNS() Object Parsing.\"\"\"\n\n    # Create our object\n    a = Apprise()\n\n    # Now test failing variations of our URL\n    assert a.add(\"sns://\") is False\n    assert a.add(\"sns://nosecret\") is False\n    assert a.add(\"sns://nosecret/noregion/\") is False\n\n    # This is valid but without valid recipients; while it's still a valid URL\n    # it won't do much when the user goes to send a notification\n    assert a.add(\"sns://norecipient/norecipient/us-west-2\") is True\n    assert len(a) == 1\n\n    # Parse a good one\n    assert a.add(\"sns://oh/yeah/us-west-2/abcdtopic/+12223334444\") is True\n    assert len(a) == 2\n\n    assert a.add(\"sns://oh/yeah/us-west-2/12223334444\") is True\n    assert len(a) == 3\n\n\ndef test_plugin_sns_aws_response_handling():\n    \"\"\"NotifySNS() AWS Response Handling.\"\"\"\n    # Not a string\n    response = NotifySNS.aws_response_to_dict(None)\n    assert response[\"type\"] is None\n    assert response[\"request_id\"] is None\n\n    # Invalid XML\n    response = NotifySNS.aws_response_to_dict(\n        '<Bad Response xmlns=\"http://sns.amazonaws.com/doc/2010-03-31/\">'\n    )\n    assert response[\"type\"] is None\n    assert response[\"request_id\"] is None\n\n    # Single Element in XML\n    response = NotifySNS.aws_response_to_dict(\n        \"<SingleElement></SingleElement>\"\n    )\n    assert response[\"type\"] == \"SingleElement\"\n    assert response[\"request_id\"] is None\n\n    # Empty String\n    response = NotifySNS.aws_response_to_dict(\"\")\n    assert response[\"type\"] is None\n    assert response[\"request_id\"] is None\n\n    response = NotifySNS.aws_response_to_dict(\"\"\"\n        <PublishResponse xmlns=\"http://sns.amazonaws.com/doc/2010-03-31/\">\n            <PublishResult>\n                <MessageId>5e16935a-d1fb-5a31-a716-c7805e5c1d2e</MessageId>\n            </PublishResult>\n            <ResponseMetadata>\n                <RequestId>dc258024-d0e6-56bb-af1b-d4fe5f4181a4</RequestId>\n            </ResponseMetadata>\n        </PublishResponse>\n        \"\"\")\n    assert response[\"type\"] == \"PublishResponse\"\n    assert response[\"request_id\"] == \"dc258024-d0e6-56bb-af1b-d4fe5f4181a4\"\n    assert response[\"message_id\"] == \"5e16935a-d1fb-5a31-a716-c7805e5c1d2e\"\n\n    response = NotifySNS.aws_response_to_dict(\"\"\"\n         <CreateTopicResponse xmlns=\"http://sns.amazonaws.com/doc/2010-03-31/\">\n           <CreateTopicResult>\n             <TopicArn>arn:aws:sns:us-east-1:000000000000:abcd</TopicArn>\n                </CreateTopicResult>\n            <ResponseMetadata>\n                <RequestId>604bef0f-369c-50c5-a7a4-bbd474c83d6a</RequestId>\n            </ResponseMetadata>\n        </CreateTopicResponse>\n        \"\"\")\n    assert response[\"type\"] == \"CreateTopicResponse\"\n    assert response[\"request_id\"] == \"604bef0f-369c-50c5-a7a4-bbd474c83d6a\"\n    assert response[\"topic_arn\"] == \"arn:aws:sns:us-east-1:000000000000:abcd\"\n\n    response = NotifySNS.aws_response_to_dict(\"\"\"\n        <ErrorResponse xmlns=\"http://sns.amazonaws.com/doc/2010-03-31/\">\n            <Error>\n                <Type>Sender</Type>\n                <Code>InvalidParameter</Code>\n                <Message>Invalid parameter: TopicArn or TargetArn Reason:\n                no value for required parameter</Message>\n            </Error>\n            <RequestId>b5614883-babe-56ca-93b2-1c592ba6191e</RequestId>\n        </ErrorResponse>\n        \"\"\")\n    assert response[\"type\"] == \"ErrorResponse\"\n    assert response[\"request_id\"] == \"b5614883-babe-56ca-93b2-1c592ba6191e\"\n    assert response[\"error_type\"] == \"Sender\"\n    assert response[\"error_code\"] == \"InvalidParameter\"\n    assert response[\"error_message\"].startswith(\"Invalid parameter:\")\n    assert response[\"error_message\"].endswith(\"required parameter\")\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sns_aws_topic_handling(mock_post):\n    \"\"\"NotifySNS() AWS Topic Handling.\"\"\"\n\n    arn_response = \"\"\"\n         <CreateTopicResponse xmlns=\"http://sns.amazonaws.com/doc/2010-03-31/\">\n           <CreateTopicResult>\n             <TopicArn>arn:aws:sns:us-east-1:000000000000:abcd</TopicArn>\n                </CreateTopicResult>\n            <ResponseMetadata>\n                <RequestId>604bef0f-369c-50c5-a7a4-bbd474c83d6a</RequestId>\n            </ResponseMetadata>\n        </CreateTopicResponse>\n        \"\"\"\n\n    def post(url, data, **kwargs):\n        \"\"\"Since Publishing a token requires 2 posts, we need to return our\n        response depending on what step we're on.\"\"\"\n\n        # A request\n        robj = mock.Mock()\n        robj.text = \"\"\n        robj.status_code = requests.codes.ok\n\n        if data.find(\"=CreateTopic\") >= 0:\n            # Topic Post Failure\n            robj.status_code = requests.codes.bad_request\n\n        return robj\n\n    # Assign ourselves a new function\n    mock_post.side_effect = post\n\n    # Create our object\n    a = Apprise()\n\n    a.add([\n        # Single Topic\n        \"sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnl/us-west-2/TopicA\",\n        # Multi-Topic\n        \"sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnl/us-east-1/TopicA/TopicB/\"\n        # Topic-Mix\n        \"sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkce/us-west-2/\"\n        \"12223334444/TopicA\",\n    ])\n\n    # CreateTopic fails\n    assert a.notify(title=\"\", body=\"test\") is False\n\n    def post(url, data, **kwargs):\n        \"\"\"Since Publishing a token requires 2 posts, we need to return our\n        response depending on what step we're on.\"\"\"\n\n        # A request\n        robj = mock.Mock()\n        robj.text = \"\"\n        robj.status_code = requests.codes.ok\n\n        if data.find(\"=CreateTopic\") >= 0:\n            robj.text = arn_response\n\n        # Manipulate Topic Publishing only (not phone)\n        elif data.find(\"=Publish\") >= 0 and data.find(\"TopicArn=\") >= 0:\n            # Topic Post Failure\n            robj.status_code = requests.codes.bad_request\n\n        return robj\n\n    # Assign ourselves a new function\n    mock_post.side_effect = post\n\n    # Publish fails\n    assert a.notify(title=\"\", body=\"test\") is False\n\n    # Disable our side effect\n    mock_post.side_effect = None\n\n    # Handle case where TopicArn is missing:\n    robj = mock.Mock()\n    robj.text = \"<CreateTopicResponse></CreateTopicResponse>\"\n    robj.status_code = requests.codes.ok\n\n    # Assign ourselves a new function\n    mock_post.return_value = robj\n    assert a.notify(title=\"\", body=\"test\") is False\n\n    # Handle case where we fails get a bad response\n    robj = mock.Mock()\n    robj.text = \"\"\n    robj.status_code = requests.codes.bad_request\n    mock_post.return_value = robj\n    assert a.notify(title=\"\", body=\"test\") is False\n\n    # Handle case where we get a valid response and TopicARN\n    robj = mock.Mock()\n    robj.text = arn_response\n    robj.status_code = requests.codes.ok\n    mock_post.return_value = robj\n    # We would have failed to make Post\n    assert a.notify(title=\"\", body=\"test\") is True\n\n\ndef test_plugin_sns_detailed_failures(mocker):\n    \"\"\"\n    Test specific failure modes (HTTP 400) for SMS, Topic Creation,\n    and Topic Publishing to ensure error counters are incremented.\n    \"\"\"\n    from apprise.plugins.sns import NotifySNS\n\n    # Mock requests.post\n    mock_post = mocker.patch(\"requests.post\")\n\n    # --- Scenario 1: SMS (Phone) Failure ---\n    obj_sms = NotifySNS(\n        access_key_id=\"key\",\n        secret_access_key=\"secret\",\n        region_name=\"us-east-1\",\n        targets=[\"+15555555555\"]\n    )\n\n    # Force a 400 Bad Request\n    mock_response_bad = mocker.Mock()\n    mock_response_bad.status_code = 400\n    mock_response_bad.text = (\n        \"<ErrorResponse><Error><Message>Fail\"\n        \"</Message></Error></ErrorResponse>\"\n    )\n\n    mock_response_bad.content = mock_response_bad.text.encode(\"utf-8\")\n    mock_post.return_value = mock_response_bad\n\n    # Should return False because the SMS failed\n    assert obj_sms.notify(body=\"test\") is False\n\n    # --- Scenario 2: Topic Creation Failure ---\n    obj_topic = NotifySNS(\n        access_key_id=\"key\",\n        secret_access_key=\"secret\",\n        region_name=\"us-east-1\",\n        targets=[\"#MyTopic\"]\n    )\n\n    # Force 400 on ANY request (which includes the first one: CreateTopic)\n    mock_post.return_value = mock_response_bad\n\n    # Should return False because CreateTopic failed\n    assert obj_topic.notify(body=\"test\") is False\n\n    # --- Scenario 3: CreateTopic Success, but Publish Failure ---\n    # We need a side_effect to return 200 for the first call (CreateTopic)\n    # and 400 for the second (Publish)\n\n    mock_response_ok = mocker.Mock()\n    mock_response_ok.status_code = 200\n    mock_response_ok.text = \"\"\"\n    <CreateTopicResponse>\n        <CreateTopicResult>\n            <TopicArn>arn:aws:sns:us-east-1:123456789012:MyTopic</TopicArn>\n        </CreateTopicResult>\n    </CreateTopicResponse>\n    \"\"\"\n    mock_response_ok.content = mock_response_ok.text.encode(\"utf-8\")\n\n    def side_effect(*args, **kwargs):\n        data = kwargs.get(\"data\", \"\")\n        if \"Action=CreateTopic\" in data:\n            return mock_response_ok\n        if \"Action=Publish\" in data:\n            return mock_response_bad\n        return mock_response_ok\n\n    mock_post.side_effect = side_effect\n\n    # Should return False because Publish failed\n    assert obj_topic.notify(body=\"test\") is False\n"
  },
  {
    "path": "tests/test_plugin_sparkpost.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.sparkpost import NotifySparkPost\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"sparkpost://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"sparkpost://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No Token specified\n    (\n        \"sparkpost://user@localhost.localdomain\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Token is valid, but no user name specified\n    (\n        \"sparkpost://localhost.localdomain/{}\".format(\"a\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid from email address\n    (\n        'sparkpost://\"@localhost.localdomain/{}'.format(\"b\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # No To email address, but everything else is valid\n    (\n        \"sparkpost://user@localhost.localdomain/{}\".format(\"c\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    (\n        \"sparkpost://user@localhost.localdomain/{}?format=markdown\".format(\n            \"d\" * 32\n        ),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    (\n        \"sparkpost://user@localhost.localdomain/{}?format=html\".format(\n            \"d\" * 32\n        ),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n            \"force_debug\": True,\n        },\n    ),\n    (\n        \"sparkpost://user@localhost.localdomain/{}?format=text\".format(\n            \"d\" * 32\n        ),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # valid url with region specified (case insensitve)\n    (\n        \"sparkpost://user@localhost.localdomain/{}?region=uS\".format(\"d\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # valid url with region specified (case insensitve)\n    (\n        \"sparkpost://user@localhost.localdomain/{}?region=EU\".format(\"e\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # headers\n    (\n        \"sparkpost://user@localhost.localdomain/{}\"\n        \"?+X-Customer-Campaign-ID=Apprise\".format(\"f\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # template tokens\n    (\n        \"sparkpost://user@localhost.localdomain/{}\"\n        \"?:name=Chris&:status=admin\".format(\"g\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # bcc and cc\n    (\n        \"sparkpost://user@localhost.localdomain/{}\"\n        \"?bcc=user@example.com&cc=user2@example.com\".format(\"h\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # invalid url with region specified (case insensitve)\n    (\n        \"sparkpost://user@localhost.localdomain/{}?region=invalid\".format(\n            \"a\" * 32\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # One 'To' Email address\n    (\n        \"sparkpost://user@localhost.localdomain/{}/test@example.com\".format(\n            \"a\" * 32\n        ),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # Invalid 'To' Email address\n    (\n        \"sparkpost://user@localhost.localdomain/{}/invalid\".format(\"i\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            # Expected notify() response\n            \"notify_response\": False,\n        },\n    ),\n    # Multiple 'To', 'Cc', and 'Bcc' addresses (with invalid ones)\n    (\n        \"sparkpost://user@example.com/{}/{}?bcc={}&cc={}\".format(\n            \"j\" * 32,\n            \"/\".join(\n                (\"user1@example.com\", \"invalid\", \"User2:user2@example.com\")\n            ),\n            \",\".join((\"user3@example.com\", \"i@v\", \"User1:user1@example.com\")),\n            \",\".join((\"user4@example.com\", \"g@r@b\", \"Da:user5@example.com\")),\n        ),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    (\n        \"sparkpost://user@localhost.localdomain/{}?to=test@example.com\".format(\n            \"k\" * 32\n        ),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # One To Email address, a from name specified too\n    (\n        \"sparkpost://user@localhost.localdomain/{}/\"\n        'test@example.com?name=\"Frodo\"'.format(\"l\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": {\n                \"results\": {\n                    \"total_rejected_recipients\": 0,\n                    \"total_accepted_recipients\": 1,\n                    \"id\": \"11668787484950529\",\n                }\n            },\n        },\n    ),\n    # Test invalid JSON response\n    (\n        \"sparkpost://user@localhost.localdomain/{}\".format(\"m\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            \"requests_response_text\": \"{\",\n        },\n    ),\n    (\n        \"sparkpost://user@localhost.localdomain/{}\".format(\"n\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"sparkpost://user@localhost.localdomain/{}\".format(\"o\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"sparkpost://user@localhost.localdomain/{}\".format(\"p\" * 32),\n        {\n            \"instance\": NotifySparkPost,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_sparkpost_urls():\n    \"\"\"NotifySparkPost() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sparkpost_throttling(mock_post):\n    \"\"\"NotifySparkPost() Throttling.\"\"\"\n\n    NotifySparkPost.sparkpost_retry_wait_sec = 0.1\n    NotifySparkPost.sparkpost_retry_attempts = 3\n\n    # API Key\n    apikey = \"abc123\"\n    user = \"user\"\n    host = \"example.com\"\n    targets = f\"{user}@{host}\"\n\n    # Exception should be thrown about the fact no user was specified\n    with pytest.raises(TypeError):\n        NotifySparkPost(apikey=apikey, targets=targets, host=host)\n\n    # Exception should be thrown about the fact no private key was specified\n    with pytest.raises(TypeError):\n        NotifySparkPost(apikey=None, targets=targets, user=user, host=host)\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = dumps({\n        \"results\": {\n            \"total_rejected_recipients\": 0,\n            \"total_accepted_recipients\": 1,\n            \"id\": \"11668787484950529\",\n        }\n    })\n\n    retry_response = requests.Request()\n    retry_response.status_code = requests.codes.too_many_requests\n    retry_response.content = dumps({\n        \"errors\": [{\n            \"description\": \"Unconfigured or unverified sending domain.\",\n            \"code\": \"7001\",\n            \"message\": \"Invalid domain\",\n        }]\n    })\n\n    # Prepare Mock (force 2 retry responses and then one okay)\n    mock_post.side_effect = (retry_response, retry_response, okay_response)\n\n    obj = Apprise.instantiate(\n        f\"sparkpost://user@localhost.localdomain/{apikey}\"\n    )\n    assert isinstance(obj, NotifySparkPost)\n\n    # We'll successfully perform the notification as we're within\n    # our retry limit\n    assert obj.notify(\"test\") is True\n\n    mock_post.reset_mock()\n    mock_post.side_effect = (retry_response, retry_response, retry_response)\n\n    # Now we are less than our expected limit check so we will fail\n    assert obj.notify(\"test\") is False\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_sparkpost_attachments(mock_post):\n    \"\"\"NotifySparkPost() Attachments.\"\"\"\n    NotifySparkPost.sparkpost_retry_wait_sec = 0.1\n    NotifySparkPost.sparkpost_retry_attempts = 3\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = dumps({\n        \"results\": {\n            \"total_rejected_recipients\": 0,\n            \"total_accepted_recipients\": 1,\n            \"id\": \"11668787484950529\",\n        }\n    })\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    # API Key\n    apikey = \"abc123\"\n\n    obj = Apprise.instantiate(\n        f\"sparkpost://user@localhost.localdomain/{apikey}\"\n    )\n    assert isinstance(obj, NotifySparkPost)\n\n    # Test Valid Attachment\n    path = os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n    attach = AppriseAttachment(path)\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test invalid attachment\n    path = os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=path,\n        )\n        is False\n    )\n\n    with mock.patch(\"base64.b64encode\", side_effect=OSError()):\n        # We can't send the message if we fail to parse the data\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is False\n        )\n\n    obj = Apprise.instantiate(\n        f\"sparkpost://no-reply@example.com/{apikey}/\"\n        \"user1@example.com/user2@example.com?batch=yes\"\n    )\n    assert isinstance(obj, NotifySparkPost)\n\n    # As a batch mode, both emails can be lumped into 1\n    assert len(obj) == 1\n\n    # Force our batch to break into separate messages\n    obj.default_batch_size = 1\n\n    # We'll send 2 messages no\n    assert len(obj) == 2\n    mock_post.reset_mock()\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_post.call_count == 2\n\n    # single batch\n    mock_post.reset_mock()\n    # We'll send 1 message\n    obj.default_batch_size = 2\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n    assert mock_post.call_count == 1\n"
  },
  {
    "path": "tests/test_plugin_spike.py",
    "content": "#\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.spike import NotifySpike\n\nlogging.disable(logging.CRITICAL)\n\napprise_url_tests = (\n    (\n        \"spike://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"spike://invalid-key\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"spike://1234567890abcdef1234567890abcdef\",\n        {\n            \"instance\": NotifySpike,\n            \"privacy_url\": \"spike://****/\",\n        },\n    ),\n    (\n        \"spike://?token=1234567890abcdef1234567890abcdef\",\n        {\n            \"instance\": NotifySpike,\n            \"privacy_url\": \"spike://****/\",\n        },\n    ),\n    (\n        \"https://api.spike.sh/v1/alerts/1234567890abcdef1234567890abcdef\",\n        {\n            \"instance\": NotifySpike,\n        },\n    ),\n    (\n        \"spike://1234567890abcdef1234567890abcdef\",\n        {\n            \"instance\": NotifySpike,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"spike://1234567890abcdef1234567890abcdef\",\n        {\n            \"instance\": NotifySpike,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"spike://ffffffffffffffffffffffffffffffff\",\n        {\n            \"instance\": NotifySpike,\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_spike_urls():\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_splunk.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.splunk import NotifySplunk\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"splunk://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://routekey@%badapi%\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://abc123\",\n        {\n            # No route key provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://%badroute%@apikey\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://?apikey=abc123&routing_key=db\",\n        {\n            # We're good\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://route@abc123/entity_id\",\n        {\n            # We're good\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://route@abc123/?entity_id=my_entity\",\n        {\n            # We're good\n            \"instance\": NotifySplunk,\n        },\n    ),\n    # Support legacy URL\n    (\n        (\n            \"https://alert.victorops.com/integrations/generic/20131114/\"\n            \"alert/apikey/routing_key\"\n        ),\n        {\n            # We're good\n            \"instance\": NotifySplunk,\n        },\n    ),\n    # Support legacy URL (with entity id provided)\n    (\n        (\n            \"https://alert.victorops.com/integrations/generic/20131114/\"\n            \"alert/apikey/routing_key/entity_id\"\n        ),\n        {\n            # We're good\n            \"instance\": NotifySplunk,\n        },\n    ),\n    # support victorops:// too!\n    (\n        \"victorops://?apikey=abc123&route=db\",\n        {\n            # We're good\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://?apikey=abc123&route=db\",\n        {\n            # We're good\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=recovery\",\n        {\n            # Always Recovery Alias\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=resolve\",\n        {\n            # Always Recovery Alias\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=r\",\n        {\n            # Always Recovery (short form)\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=acknowledgement\",\n        {\n            # Always Acknowledgement\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=ack\",\n        {\n            # Always Acknowledgement (short form)\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=critical\",\n        {\n            # Always Critical\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=crit\",\n        {\n            # Always Critical (short form)\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=warning\",\n        {\n            # Always Warning\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=warn\",\n        {\n            # Always Warning (short form)\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=info\",\n        {\n            # Always INFO\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=i\",\n        {\n            # Always INFO (short form)\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?action=invalid\",\n        {\n            # Invalid Action\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://db@apikey?:warning=critical\",\n        {\n            # Map warnings to CRITICAL\n            \"instance\": NotifySplunk,\n        },\n    ),\n    (\n        \"splunk://db@apikey?:invalid=critical\",\n        {\n            # A bad Apprise Notification Type was provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://db@apikey?:warning=invalid\",\n        {\n            # A bad Splunk Notification Type was provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"splunk://db@apikey\",\n        {\n            \"instance\": NotifySplunk,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"splunk://db@apikey\",\n        {\n            \"instance\": NotifySplunk,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"splunk://db@token\",\n        {\n            \"instance\": NotifySplunk,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_splunk_urls():\n    \"\"\"NotifySplunk() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_spugpush.py",
    "content": "#\n# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.spugpush import NotifySpugpush\n\nlogging.disable(logging.CRITICAL)\n\napprise_url_tests = (\n    (\n        \"spugpush://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"spugpush://invalid!\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"spugpush://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifySpugpush,\n            \"privacy_url\": \"spugpush://****/\",\n        },\n    ),\n    (\n        \"spugpush://?token=abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifySpugpush,\n            \"privacy_url\": \"spugpush://****/\",\n        },\n    ),\n    (\n        \"https://push.spug.dev/send/abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifySpugpush,\n        },\n    ),\n    (\n        \"spugpush://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifySpugpush,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"spugpush://abc123def456ghi789jkl012mno345pq\",\n        {\n            \"instance\": NotifySpugpush,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"spugpush://ffffffffffffffffffffffffffffffff\",\n        {\n            \"instance\": NotifySpugpush,\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_spugpush_urls():\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_streamlabs.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\n\nfrom apprise.plugins.streamlabs import NotifyStreamlabs\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"strmlabs://\",\n        {\n            # No Access Token specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"strmlabs://a_bd_/\",\n        {\n            # invalid Access Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso\",\n        {\n            # access token\n            \"instance\": NotifyStreamlabs,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"strmlabs://I...o\",\n        },\n    ),\n    # Test incorrect currency\n    (\n        \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?currency=ABCD\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Test complete params - donations\n    (\n        (\n            \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/\"\n            \"?name=tt&identifier=pyt&amount=20&currency=USD&call=donations\"\n        ),\n        {\n            \"instance\": NotifyStreamlabs,\n        },\n    ),\n    # Test complete params - donations\n    (\n        (\n            \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/\"\n            \"?image_href=https://example.org/rms.jpg\"\n            \"&sound_href=https://example.org/rms.mp3\"\n        ),\n        {\n            \"instance\": NotifyStreamlabs,\n        },\n    ),\n    # Test complete params - alerts\n    (\n        (\n            \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/\"\n            \"?duration=1000&image_href=&\"\n            \"sound_href=&alert_type=donation&special_text_color=crimson\"\n        ),\n        {\n            \"instance\": NotifyStreamlabs,\n        },\n    ),\n    # Test incorrect call\n    (\n        (\n            \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/\"\n            \"?name=tt&identifier=pyt&amount=20&currency=USD&call=rms\"\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Test incorrect alert_type\n    (\n        (\n            \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/\"\n            \"?name=tt&identifier=pyt&amount=20&currency=USD&alert_type=rms\"\n        ),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Test incorrect name\n    (\n        \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?name=t\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=donations\",\n        {\n            \"instance\": NotifyStreamlabs,\n            # A failure has status set to zero\n            # Test without an 'error' flag\n            \"requests_response_text\": {\n                \"status\": 0,\n            },\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=alerts\",\n        {\n            \"instance\": NotifyStreamlabs,\n            # A failure has status set to zero\n            # Test without an 'error' flag\n            \"requests_response_text\": {\n                \"status\": 0,\n            },\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=alerts\",\n        {\n            \"instance\": NotifyStreamlabs,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"strmlabs://IcIcArukDQtuC1is1X1UdKZjTg118Lag2vScOmso/?call=donations\",\n        {\n            \"instance\": NotifyStreamlabs,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_streamlabs_urls():\n    \"\"\"NotifyStreamlabs() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_synology.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.synology import NotifySynology\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"synology://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"synology://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"synologys://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"synology://localhost/token\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    (\n        \"synology://localhost/token?file_url=http://reddit.com/test.jpg\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    (\n        \"synology://user:pass@localhost/token\",\n        {\n            \"instance\": NotifySynology,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"synology://user:****@localhost/t...n\",\n        },\n    ),\n    # No token was specified\n    (\n        \"synology://user@localhost\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"synology://user@localhost/token\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    # Continue testing other cases\n    (\n        \"synology://localhost:8080/token\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    (\n        \"synology://user:pass@localhost:8080/token\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    (\n        \"synologys://localhost/token\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    (\n        \"synologys://localhost/?token=mytoken\",\n        {\n            \"instance\": NotifySynology,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"synologys://localhost/m...n\",\n        },\n    ),\n    (\n        \"synologys://user:pass@localhost/token\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    (\n        \"synologys://localhost:8080/token/path/\",\n        {\n            \"instance\": NotifySynology,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"synologys://localhost:8080/t...n/path/\",\n        },\n    ),\n    (\n        \"synologys://user:password@localhost:8080/token\",\n        {\n            \"instance\": NotifySynology,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"synologys://user:****@localhost:8080/t...n\",\n        },\n    ),\n    # Test our Headers\n    (\n        \"synology://localhost:8080/path?+HeaderKey=HeaderValue\",\n        {\n            \"instance\": NotifySynology,\n        },\n    ),\n    (\n        \"synology://user:pass@localhost:8081/token\",\n        {\n            \"instance\": NotifySynology,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"synology://user:pass@localhost:8082/token\",\n        {\n            \"instance\": NotifySynology,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"synology://user:pass@localhost:8083/token\",\n        {\n            \"instance\": NotifySynology,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_custom_synology_urls():\n    \"\"\"NotifySynology() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_synology_edge_cases(mock_post):\n    \"\"\"NotifySynology() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # This string also tests that type is set to nothing\n    results = NotifySynology.parse_url(\n        \"synology://user:pass@localhost:8080/token\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == \"user\"\n    assert results[\"password\"] == \"pass\"\n    assert results[\"port\"] == 8080\n    assert results[\"host\"] == \"localhost\"\n    assert results[\"fullpath\"] == \"\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] == \"token\"\n    assert results[\"schema\"] == \"synology\"\n    assert results[\"url\"] == \"synology://user:pass@localhost:8080/token\"\n\n    instance = NotifySynology(**results)\n    assert isinstance(instance, NotifySynology)\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"http://localhost:8080/webapi/entry.cgi\"\n\n    assert details[1][\"data\"].startswith(\"payload=\")\n"
  },
  {
    "path": "tests/test_plugin_syslog.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nimport sys\nfrom unittest import mock\n\nimport pytest\n\nimport apprise\n\ntry:\n    import syslog\n\nexcept ImportError:\n    # Shim so that test cases can run in environments that\n    # do not have syslog\n    import types\n    syslog = types.SimpleNamespace(\n        LOG_PID=0x01,\n        LOG_PERROR=0x02,\n        LOG_INFO=6,\n        LOG_NOTICE=5,\n        LOG_CRIT=2,\n        LOG_WARNING=4,\n        LOG_KERN=0,\n        LOG_USER=1,\n        LOG_MAIL=2,\n        LOG_DAEMON=3,\n        LOG_AUTH=4,\n        LOG_SYSLOG=5,\n        LOG_LPR=6,\n        LOG_NEWS=7,\n        LOG_UUCP=8,\n        LOG_CRON=9,\n        LOG_LOCAL0=16,\n        LOG_LOCAL1=17,\n        LOG_LOCAL2=18,\n        LOG_LOCAL3=19,\n        LOG_LOCAL4=20,\n        LOG_LOCAL5=21,\n        LOG_LOCAL6=22,\n        LOG_LOCAL7=23,\n        openlog=lambda *a, **kw: None,\n        syslog=lambda *a, **kw: None,\n    )\n    sys.modules[\"syslog\"] = syslog\n\n\nlogging.disable(logging.CRITICAL)\n\nfrom apprise.plugins.syslog import NotifySyslog  # noqa E402\n\n\n@mock.patch(\"syslog.syslog\")\n@mock.patch(\"syslog.openlog\")\ndef test_plugin_syslog_by_url(openlog, syslog):\n    \"\"\"NotifySyslog() Apprise URLs.\"\"\"\n    # an invalid URL\n    assert NotifySyslog.parse_url(object) is None\n    assert NotifySyslog.parse_url(42) is None\n    assert NotifySyslog.parse_url(None) is None\n\n    obj = apprise.Apprise.instantiate(\"syslog://\")\n    assert obj.url().startswith(\"syslog://user\")\n    assert r\"logpid=yes\" in obj.url()\n    assert r\"logperror=no\" in obj.url()\n\n    # We do not support generation of a URL ID\n    assert obj.url_id() is None\n\n    assert isinstance(\n        apprise.Apprise.instantiate(\"syslog://:@/\"), NotifySyslog\n    )\n\n    obj = apprise.Apprise.instantiate(\"syslog://?logpid=no&logperror=yes\")\n    assert isinstance(obj, NotifySyslog)\n    assert obj.url().startswith(\"syslog://user\")\n    assert r\"logpid=no\" in obj.url()\n    assert r\"logperror=yes\" in obj.url()\n\n    # Test sending a notification\n    assert obj.notify(\"body\") is True\n    assert obj.notify(title=\"title\", body=\"body\") is True\n\n    # Invalid Notification Type\n    assert obj.notify(\"body\", notify_type=\"invalid\") is False\n\n    obj = apprise.Apprise.instantiate(\"syslog://_/?facility=local5\")\n    assert isinstance(obj, NotifySyslog)\n    assert obj.url().startswith(\"syslog://local5\")\n    assert r\"logpid=yes\" in obj.url()\n    assert r\"logperror=no\" in obj.url()\n\n    # Invalid instantiation\n    assert apprise.Apprise.instantiate(\"syslog://_/?facility=invalid\") is None\n\n    # j will cause a search to take place and match to daemon\n    obj = apprise.Apprise.instantiate(\"syslog://_/?facility=d\")\n    assert isinstance(obj, NotifySyslog)\n    assert obj.url().startswith(\"syslog://daemon\")\n    assert r\"logpid=yes\" in obj.url()\n    assert r\"logperror=no\" in obj.url()\n\n    # Facility can also be specified on the url as a hostname\n    obj = apprise.Apprise.instantiate(\"syslog://kern?logpid=no&logperror=y\")\n    assert isinstance(obj, NotifySyslog)\n    assert obj.url().startswith(\"syslog://kern\")\n    assert r\"logpid=no\" in obj.url()\n    assert r\"logperror=yes\" in obj.url()\n\n    # Facilities specified as an argument always over-ride host\n    obj = apprise.Apprise.instantiate(\"syslog://kern?facility=d\")\n    assert isinstance(obj, NotifySyslog)\n    assert obj.url().startswith(\"syslog://daemon\")\n\n\n@mock.patch(\"syslog.syslog\")\n@mock.patch(\"syslog.openlog\")\ndef test_plugin_syslog_edge_cases(openlog, syslog):\n    \"\"\"NotifySyslog() Edge Cases.\"\"\"\n\n    # Default\n    obj = NotifySyslog(facility=None)\n    assert isinstance(obj, NotifySyslog)\n    assert obj.url().startswith(\"syslog://user\")\n    assert r\"logpid=yes\" in obj.url()\n    assert r\"logperror=no\" in obj.url()\n\n    # Exception should be thrown about the fact no bot token was specified\n    with pytest.raises(TypeError):\n        NotifySyslog(facility=\"invalid\")\n\n    with pytest.raises(TypeError):\n        NotifySyslog(facility=object)\n"
  },
  {
    "path": "tests/test_plugin_techululs_push.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.techuluspush import NotifyTechulusPush\n\nlogging.disable(logging.CRITICAL)\n\n# a test UUID we can use\nUUID4 = \"8b799edf-6f98-4d3a-9be7-2862fb4e5752\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"push://\",\n        {\n            # Missing API Key\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid API Key\n    (\n        \"push://%s\" % (\"+\" * 24),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # APIkey\n    (\n        f\"push://{UUID4}\",\n        {\n            \"instance\": NotifyTechulusPush,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"push://8...2/\",\n        },\n    ),\n    # API Key + bad url\n    (\n        \"push://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        f\"push://{UUID4}\",\n        {\n            \"instance\": NotifyTechulusPush,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        f\"push://{UUID4}\",\n        {\n            \"instance\": NotifyTechulusPush,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        f\"push://{UUID4}\",\n        {\n            \"instance\": NotifyTechulusPush,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_techulus_push_urls():\n    \"\"\"NotifyTechulusPush() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_telegram.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import (\n    Apprise,\n    AppriseAsset,\n    AppriseAttachment,\n    NotifyFormat,\n    NotifyType,\n)\nfrom apprise.plugins.telegram import NotifyTelegram\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyTelegram\n    ##################################\n    (\n        \"tgram://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # Simple Message\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Simple Message (no images)\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # Simple Message with multiple chat names\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/id1/id2/\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Simple Message with multiple chat names\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/?to=id1,id2\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Simple Message with an invalid chat ID\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/%$/\",\n        {\n            \"instance\": NotifyTelegram,\n            # Notify will fail\n            \"response\": False,\n        },\n    ),\n    # Simple Message with multiple chat ids\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/id1/id2/23423/-30/\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Simple Message with multiple chat ids (no images)\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/id1/id2/23423/-30/\",\n        {\n            \"instance\": NotifyTelegram,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # Support bot keyword prefix\n    (\n        \"tgram://bottest@123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Support Thread Topics\n    (\n        \"tgram://bottest@123456789:abcdefg_hijklmnop/id1/?topic=12345\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Thread is just an alias of topic\n    (\n        \"tgram://bottest@123456789:abcdefg_hijklmnop/id1/?thread=12345\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Threads must be numeric\n    (\n        \"tgram://bottest@123456789:abcdefg_hijklmnop/id1/?topic=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # content must be 'before' or 'after'\n    (\n        \"tgram://bottest@123456789:abcdefg_hijklmnop/id1/?content=invalid\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"tgram://bottest@123456789:abcdefg_hijklmnop/id1:invalid/?thread=12345\",\n        {\n            \"instance\": NotifyTelegram,\n            # Notify will fail (bad target)\n            \"response\": False,\n        },\n    ),\n    # Testing image\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Testing invalid format (fall's back to html)\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=invalid\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Testing empty format (falls back to html)\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Testing valid formats\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=markdown\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=markdown&mdv=v1\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=markdown&mdv=v2\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/l2g/?format=markdown&mdv=bad\",\n        {\n            # Defaults to v2\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=html\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?format=text\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Test Silent Settings\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?silent=yes\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?silent=no\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Test Web Page Preview Settings\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?preview=yes\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?preview=no\",\n        {\n            \"instance\": NotifyTelegram,\n        },\n    ),\n    # Simple Message without image\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    # Invalid Bot Token\n    (\n        \"tgram://alpha:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    # AuthToken + bad url\n    (\n        \"tgram://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes\",\n        {\n            \"instance\": NotifyTelegram,\n            # force a failure without an image specified\n            \"include_image\": False,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/id1/id2/\",\n        {\n            \"instance\": NotifyTelegram,\n            # force a failure with multiple chat_ids\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/id1/id2/\",\n        {\n            \"instance\": NotifyTelegram,\n            # force a failure without an image specified\n            \"include_image\": False,\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n            # throw a bizarre code forcing us to fail to look it up without\n            # having an image included\n            \"include_image\": False,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    # Test with image set\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes\",\n        {\n            \"instance\": NotifyTelegram,\n            # throw a bizarre code forcing us to fail to look it up without\n            # having an image included\n            \"include_image\": True,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/\",\n        {\n            \"instance\": NotifyTelegram,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"tgram://123456789:abcdefg_hijklmnop/lead2gold/?image=Yes\",\n        {\n            \"instance\": NotifyTelegram,\n            # Throws a series of i/o exceptions with this flag is set and\n            # tests that we gracefully handle them without images set\n            \"include_image\": True,\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_telegram_urls():\n    \"\"\"NotifyTelegram() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_telegram_general(mock_post):\n    \"\"\"NotifyTelegram() General Tests.\"\"\"\n\n    # Bot Token\n    bot_token = \"123456789:abcdefg_hijklmnop\"\n    invalid_bot_token = \"abcd:123\"\n\n    # Chat ID\n    chat_ids = \"l2g:1234, lead2gold\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = \"{}\"\n\n    # Exception should be thrown about the fact no bot token was specified\n    with pytest.raises(TypeError):\n        NotifyTelegram(bot_token=None, targets=chat_ids)\n\n    # Invalid JSON while trying to detect bot owner\n    mock_post.return_value.content = \"}\"\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    obj.notify(title=\"hello\", body=\"world\")\n\n    # Invalid JSON while trying to detect bot owner + 400 error\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    obj.notify(title=\"hello\", body=\"world\")\n\n    # Return status back to how they were\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Exception should be thrown about the fact an invalid bot token was\n    # specifed\n    with pytest.raises(TypeError):\n        NotifyTelegram(bot_token=invalid_bot_token, targets=chat_ids)\n\n    obj = NotifyTelegram(\n        bot_token=bot_token, targets=chat_ids, include_image=True\n    )\n    assert isinstance(obj, NotifyTelegram) is True\n    assert len(obj.targets) == 2\n\n    # Test Image Sending Exceptions\n    mock_post.side_effect = OSError()\n    assert not obj.send_media(obj.targets[0], NotifyType.INFO)\n\n    # Test our other objects\n    mock_post.side_effect = requests.HTTPError\n    assert not obj.send_media(obj.targets[0], NotifyType.INFO)\n\n    # Restore their entries\n    mock_post.side_effect = None\n    mock_post.return_value.content = \"{}\"\n\n    # test url call\n    assert isinstance(obj.url(), str) is True\n\n    # test privacy version of url\n    assert isinstance(obj.url(privacy=True), str) is True\n    assert obj.url(privacy=True).startswith(\"tgram://1...p/\") is True\n\n    # Test that we can load the string we generate back:\n    obj = NotifyTelegram(**NotifyTelegram.parse_url(obj.url()))\n    assert isinstance(obj, NotifyTelegram) is True\n\n    # Prepare Mock to fail\n    response = mock.Mock()\n    response.status_code = requests.codes.internal_server_error\n\n    # a error response\n    response.content = dumps({\n        \"description\": \"test\",\n    })\n    mock_post.return_value = response\n\n    # No image asset\n    nimg_obj = NotifyTelegram(bot_token=bot_token, targets=chat_ids)\n    nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False)\n\n    # Test that our default settings over-ride base settings since they are\n    # not the same as the one specified in the base; this check merely\n    # ensures our plugin inheritance is working properly\n    assert obj.body_maxlen == NotifyTelegram.body_maxlen\n\n    # This tests erroneous messages involving multiple chat ids\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    assert (\n        nimg_obj.notify(\n            body=\"body\", title=\"title\", notify_type=NotifyType.INFO\n        )\n        is False\n    )\n\n    # This tests erroneous messages involving a single chat id\n    obj = NotifyTelegram(bot_token=bot_token, targets=\"l2g\")\n    nimg_obj = NotifyTelegram(bot_token=bot_token, targets=\"l2g\")\n    nimg_obj.asset = AppriseAsset(image_path_mask=False, image_url_mask=False)\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    assert (\n        nimg_obj.notify(\n            body=\"body\", title=\"title\", notify_type=NotifyType.INFO\n        )\n        is False\n    )\n\n    # Bot Token Detection\n    # Just to make it clear to people reading this code and trying to learn\n    # what is going on.  Apprise tries to detect the bot owner if you don't\n    # specify a user to message.  The idea is to just default to messaging\n    # the bot owner himself (it makes it easier for people).  So we're testing\n    # the creating of a Telegram Notification without providing a chat ID.\n    # We're testing the error handling of this bot detection section of the\n    # code\n    mock_post.return_value.content = dumps({\n        \"ok\": True,\n        \"result\": [\n            {\n                \"update_id\": 645421319,\n                # Entry without `message` in it\n            },\n            {\n                # Entry without `from` in `message`\n                \"update_id\": 645421320,\n                \"message\": {\n                    \"message_id\": 2,\n                    \"chat\": {\n                        \"id\": 532389719,\n                        \"first_name\": \"Chris\",\n                        \"type\": \"private\",\n                    },\n                    \"date\": 1519694394,\n                    \"text\": \"/start\",\n                    \"entities\": [{\n                        \"offset\": 0,\n                        \"length\": 6,\n                        \"type\": \"bot_command\",\n                    }],\n                },\n            },\n            {\n                \"update_id\": 645421321,\n                \"message\": {\n                    \"message_id\": 2,\n                    \"from\": {\n                        \"id\": 532389719,\n                        \"is_bot\": False,\n                        \"first_name\": \"Chris\",\n                        \"language_code\": \"en-US\",\n                    },\n                    \"chat\": {\n                        \"id\": 532389719,\n                        \"first_name\": \"Chris\",\n                        \"type\": \"private\",\n                    },\n                    \"date\": 1519694394,\n                    \"text\": \"/start\",\n                    \"entities\": [{\n                        \"offset\": 0,\n                        \"length\": 6,\n                        \"type\": \"bot_command\",\n                    }],\n                },\n            },\n        ],\n    })\n    mock_post.return_value.status_code = requests.codes.ok\n\n    obj = NotifyTelegram(bot_token=bot_token, targets=\"12345\")\n    assert len(obj.targets) == 1\n    assert obj.targets[0] == (12345, None)\n\n    # Test the escaping of characters since Telegram escapes stuff for us to\n    # which we need to consider\n    mock_post.reset_mock()\n    body = \"<p>'\\\"This can't\\t\\r\\nfail&nbsp;us\\\"'</p>\"\n    assert (\n        obj.notify(\n            body=body, title=\"special characters\", notify_type=NotifyType.INFO\n        )\n        is True\n    )\n    assert mock_post.call_count == 1\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # Test our payload\n    assert (\n        payload[\"text\"]\n        == \"<b>special characters</b>\\r\\n'\\\"This can't\\t\\r\\nfail us\\\"'\\r\\n\"\n    )\n\n    for content in (\"before\", \"after\"):\n        # Test our content settings\n        obj = NotifyTelegram(\n            bot_token=bot_token, targets=\"12345\", content=content\n        )\n        # Reset our mock\n        mock_post.reset_mock()\n        # Test sending attachments\n        attach = AppriseAttachment(\n            os.path.join(TEST_VAR_DIR, \"apprise-test.gif\")\n        )\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is True\n        )\n\n        # Test large messages\n        assert (\n            obj.notify(\n                body=\"a\" * (obj.telegram_caption_maxlen + 1),\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=attach,\n            )\n            is True\n        )\n\n        # An invalid attachment will cause a failure\n        path = os.path.join(\n            TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\"\n        )\n        attach = AppriseAttachment(path)\n        assert (\n            obj.notify(\n                body=\"body\",\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=path,\n            )\n            is False\n        )\n\n        # Test large messages\n        assert (\n            obj.notify(\n                body=\"a\" * (obj.telegram_caption_maxlen + 1),\n                title=\"title\",\n                notify_type=NotifyType.INFO,\n                attach=path,\n            )\n            is False\n        )\n\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    # No user detected; this happens after our firsst notification\n    assert len(obj.targets) == 0\n\n    assert obj.notify(title=\"hello\", body=\"world\") is True\n    assert len(obj.targets) == 1\n    assert obj.targets[0] == (\"532389719\", None)\n\n    # Do the test again, but without the expected (parsed response)\n    mock_post.return_value.content = dumps({\n        \"ok\": True,\n        \"result\": [],\n    })\n\n    # No user will be detected now\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    # No user detected; this happens after our firsst notification\n    assert len(obj.targets) == 0\n    assert obj.notify(title=\"hello\", body=\"world\") is False\n    assert len(obj.targets) == 0\n\n    # Do the test again, but with ok not set to True\n    mock_post.return_value.content = dumps({\n        \"ok\": False,\n        \"result\": [\n            {\n                \"update_id\": 645421321,\n                \"message\": {\n                    \"message_id\": 2,\n                    \"from\": {\n                        \"id\": 532389719,\n                        \"is_bot\": False,\n                        \"first_name\": \"Chris\",\n                        \"language_code\": \"en-US\",\n                    },\n                    \"chat\": {\n                        \"id\": 532389719,\n                        \"first_name\": \"Chris\",\n                        \"type\": \"private\",\n                    },\n                    \"date\": 1519694394,\n                    \"text\": \"/start\",\n                    \"entities\": [{\n                        \"offset\": 0,\n                        \"length\": 6,\n                        \"type\": \"bot_command\",\n                    }],\n                },\n            },\n        ],\n    })\n\n    # No user will be detected now\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    # No user detected; this happens after our firsst notification\n    assert len(obj.targets) == 0\n    assert obj.notify(title=\"hello\", body=\"world\") is False\n    assert len(obj.targets) == 0\n\n    # An edge case where no results were provided; this will probably never\n    # happen, but it helps with test coverage completeness\n    mock_post.return_value.content = dumps({\n        \"ok\": True,\n    })\n\n    # No user will be detected now\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    # No user detected; this happens after our firsst notification\n    assert len(obj.targets) == 0\n    assert obj.notify(title=\"hello\", body=\"world\") is False\n    assert len(obj.targets) == 0\n    # Detect the bot with a bad response\n    mock_post.return_value.content = dumps({})\n    obj.detect_bot_owner()\n\n    # Test our bot detection with a internal server error\n    mock_post.return_value.status_code = requests.codes.internal_server_error\n\n    # internal server error prevents notification from being sent\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    assert len(obj.targets) == 0\n    assert obj.notify(title=\"hello\", body=\"world\") is False\n    assert len(obj.targets) == 0\n\n    # Test our bot detection with an unmappable html error\n    mock_post.return_value.status_code = 999\n    NotifyTelegram(bot_token=bot_token, targets=None)\n    assert len(obj.targets) == 0\n    assert obj.notify(title=\"hello\", body=\"world\") is False\n    assert len(obj.targets) == 0\n\n    # Do it again but this time provide a failure message\n    mock_post.return_value.content = dumps({\"description\": \"Failure Message\"})\n    NotifyTelegram(bot_token=bot_token, targets=None)\n    assert len(obj.targets) == 0\n    assert obj.notify(title=\"hello\", body=\"world\") is False\n    assert len(obj.targets) == 0\n\n    # Do it again but this time provide a failure message and perform a\n    # notification without a bot detection by providing at least 1 chat id\n    obj = NotifyTelegram(bot_token=bot_token, targets=[\"@abcd\"])\n    assert (\n        nimg_obj.notify(\n            body=\"body\", title=\"title\", notify_type=NotifyType.INFO\n        )\n        is False\n    )\n\n    # iterate over our exceptions and test them\n    mock_post.side_effect = requests.HTTPError\n\n    # No chat_ids specified\n    obj = NotifyTelegram(bot_token=bot_token, targets=None)\n    assert len(obj.targets) == 0\n    assert obj.notify(title=\"hello\", body=\"world\") is False\n    assert len(obj.targets) == 0\n\n    # Test Telegram Group\n    obj = Apprise.instantiate(\n        \"tgram://123456789:ABCdefghijkl123456789opqyz/-123456789525\"\n    )\n    assert isinstance(obj, NotifyTelegram)\n    assert len(obj.targets) == 1\n    assert (-123456789525, None) in obj.targets\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_telegram_formatting(mock_post):\n    \"\"\"NotifyTelegram() formatting tests.\"\"\"\n\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = \"{}\"\n\n    # Simple success response\n    mock_post.return_value.content = dumps({\n        \"ok\": True,\n        \"result\": [\n            {\n                \"update_id\": 645421321,\n                \"message\": {\n                    \"message_id\": 2,\n                    \"from\": {\n                        \"id\": 532389719,\n                        \"is_bot\": False,\n                        \"first_name\": \"Chris\",\n                        \"language_code\": \"en-US\",\n                    },\n                    \"chat\": {\n                        \"id\": 532389719,\n                        \"first_name\": \"Chris\",\n                        \"type\": \"private\",\n                    },\n                    \"date\": 1519694394,\n                    \"text\": \"/start\",\n                    \"entities\": [{\n                        \"offset\": 0,\n                        \"length\": 6,\n                        \"type\": \"bot_command\",\n                    }],\n                },\n            },\n        ],\n    })\n    mock_post.return_value.status_code = requests.codes.ok\n\n    results = NotifyTelegram.parse_url(\"tgram://123456789:abcdefg_hijklmnop/\")\n\n    instance = NotifyTelegram(**results)\n    assert isinstance(instance, NotifyTelegram)\n\n    response = instance.send(title=\"title\", body=\"body\")\n    assert response is True\n    # 1 call to look up bot owner, and second for notification\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Now test our HTML Conversion as TEXT)\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/\")\n    assert len(aobj) == 1\n\n    title = \"🚨 Change detected for <i>Apprise Test Title</i>\"\n    body = (\n        '<a href=\"http://localhost\"><i>Apprise Body Title</i></a>'\n        ' had <a href=\"http://127.0.0.1\">a change</a>'\n    )\n\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT)\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a TEXT mode\n    assert (\n        payload[\"text\"]\n        == \"<b>🚨 Change detected for &lt;i&gt;Apprise Test Title&lt;/i&gt;\"\n        '</b>\\r\\n&lt;a href=\"http://localhost\"&gt;&lt;i&gt;'\n        \"Apprise Body Title&lt;/i&gt;&lt;/a&gt; had &lt;\"\n        'a href=\"http://127.0.0.1\"&gt;a change&lt;/a&gt;'\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Now test our HTML Conversion as TEXT)\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/?format=html\")\n    assert len(aobj) == 1\n\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.HTML)\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"<b>🚨 Change detected for <i>Apprise Test Title</i></b>\\r\\n\"\n        '<a href=\"http://localhost\"><i>Apprise Body Title</i></a> had '\n        '<a href=\"http://127.0.0.1\">a change</a>'\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Now test our MARKDOWN Handling\n    title = \"# 🚨 Change detected for _Apprise Test Title_\"\n    body = (\n        \"_[Apprise Body Title](http://localhost)_\"\n        \" had [a change](http://127.0.0.1)\"\n    )\n\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/?format=markdown&mdv=2\")\n    assert len(aobj) == 1\n\n    assert aobj.notify(\n        title=title, body=body, body_format=NotifyFormat.MARKDOWN\n    )\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"# 🚨 Change detected for _Apprise Test Title_\\r\\n\"\n        \"_[Apprise Body Title](http://localhost)_ had \"\n        \"[a change](http://127.0.0.1)\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Now test our MARKDOWN Handling\n    title = \"# 🚨 Change detected for _Apprise Test Title_\"\n    body = (\n        \"_[Apprise Body Title](http://localhost)_\"\n        \" had [a change](http://127.0.0.1)\"\n    )\n\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/?format=markdown&mdv=1\")\n    assert len(aobj) == 1\n\n    assert aobj.notify(\n        title=title, body=body, body_format=NotifyFormat.MARKDOWN\n    )\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot123456789:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"# 🚨 Change detected for _Apprise Test Title_\\r\\n\"\n        \"_[Apprise Body Title](http://localhost)_ had \"\n        \"[a change](http://127.0.0.1)\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Upstream to use HTML but input specified as Markdown\n    aobj = Apprise()\n    aobj.add(\"tgram://987654321:abcdefg_hijklmnop/?format=html\")\n    assert len(aobj) == 1\n\n    # Now test our MARKDOWN Handling\n    title = \"# 🚨 Another Change detected for _Apprise Test Title_\"\n    body = (\n        \"_[Apprise Body Title](http://localhost)_\"\n        \" had [a change](http://127.0.0.2)\"\n    )\n\n    # HTML forced by the command line, but MARKDOWN specified as\n    # upstream mode\n    assert aobj.notify(\n        title=title, body=body, body_format=NotifyFormat.MARKDOWN\n    )\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"<b>\\r\\n<b>🚨 Another Change detected for \"\n        \"<i>Apprise Test Title</i></b>\\r\\n</b>\\r\\n<i>\"\n        '<a href=\"http://localhost\">Apprise Body Title</a>'\n        '</i> had <a href=\"http://127.0.0.2\">a change</a>\\r\\n'\n    )\n\n    # Now we'll test an edge case where a title was defined, but after\n    # processing it, it was determiend there really wasn't anything there\n    # at all at the end of the day.\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Upstream to use HTML but input specified as Markdown v1\n    aobj = Apprise()\n    aobj.add(\"tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=1\")\n    assert len(aobj) == 1\n\n    # Now test our MARKDOWN Handling (no title defined... not really anyway)\n    title = \"# \"\n    body = (\n        \"_[Apprise Body Title](http://localhost)_\"\n        \" had [a change](http://127.0.0.2)\"\n    )\n\n    # MARKDOWN forced by the command line, but TEXT specified as\n    # upstream mode\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT)\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a MARKDOWN mode\n    assert payload[\"text\"] == body\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Upstream to use HTML but input specified as Markdown v2\n    aobj = Apprise()\n    aobj.add(\"tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=2\")\n    assert len(aobj) == 1\n\n    # MARKDOWN forced by the command line, but TEXT specified as\n    # upstream mode\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT)\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a MARKDOWN mode\n    assert (\n        payload[\"text\"]\n        == \"\\\\_\\\\[Apprise Body Title\\\\]\\\\(http://localhost\\\\)\\\\_ had \\\\\"\n        \"[a change\\\\]\\\\(http://127\\\\.0\\\\.0\\\\.2\\\\)\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Upstream to use HTML but input specified as Markdown v1\n    aobj = Apprise()\n    aobj.add(\"tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=1\")\n    assert len(aobj) == 1\n\n    # Set an actual title this time\n    title = \"# A Great Title\"\n    body = (\n        \"_[Apprise Body Title](http://localhost)_\"\n        \" had [a change](http://127.0.0.2)\"\n    )\n\n    # TEXT forced by the command line, but MARKDOWN specified as\n    # upstream mode\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT)\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a MARKDOWN mode\n    assert (\n        payload[\"text\"]\n        == \"# A Great Title\\r\\n\"\n        \"_[Apprise Body Title](http://localhost)_ had \"\n        \"[a change](http://127.0.0.2)\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # Upstream to use HTML but input specified as Markdown v2\n    aobj = Apprise()\n    aobj.add(\"tgram://987654321:abcdefg_hijklmnop/?format=markdown&mdv=2\")\n    assert len(aobj) == 1\n\n    # TEXT forced by the command line, but MARKDOWN specified as\n    # upstream mode\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT)\n\n    # Test our calls\n    assert mock_post.call_count == 2\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a MARKDOWN mode\n    assert (\n        payload[\"text\"]\n        == \"\\\\# A Great Title\\r\\n\"\n        \"\\\\_\\\\[Apprise Body Title\\\\]\\\\(http://localhost\\\\)\\\\_ had \"\n        \"\\\\[a change\\\\]\\\\(http://127\\\\.0\\\\.0\\\\.2\\\\)\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # If input is markdown and output is v2, it is expected the user knows\n    # what he is doing... no esaping takes place\n    assert aobj.notify(\n        title=title, body=body, body_format=NotifyFormat.MARKDOWN\n    )\n\n    # Test our calls\n    assert mock_post.call_count == 1\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # No escaping in this circumstance\n    assert (\n        payload[\"text\"]\n        == \"# A Great Title\\r\\n\"\n        \"_[Apprise Body Title](http://localhost)_ had \"\n        \"[a change](http://127.0.0.2)\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    # No body format specified at all... user definitely must know what\n    # they are doing... still no escaping in this circumstance\n    assert aobj.notify(title=title, body=body)\n\n    # Test our calls\n    assert mock_post.call_count == 1\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # No escaping in this circumstance\n    assert (\n        payload[\"text\"]\n        == \"# A Great Title\\r\\n\"\n        \"_[Apprise Body Title](http://localhost)_ had \"\n        \"[a change](http://127.0.0.2)\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    #\n    # Now test that <br/> is correctly escaped\n    #\n    title = \"Test Message Title\"\n    body = \"Test Message Body <br/> ok</br>\"\n\n    aobj = Apprise()\n    aobj.add(\"tgram://1234:aaaaaaaaa/-1123456245134\")\n    assert len(aobj) == 1\n\n    assert aobj.notify(\n        title=title, body=body, body_format=NotifyFormat.MARKDOWN\n    )\n\n    # Test our calls\n    assert mock_post.call_count == 1\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot1234:aaaaaaaaa/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"<b>Test Message Title\\r\\n</b>\\r\\nTest Message Body\\r\\nok\\r\\n\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    #\n    # Now test that <br/> is correctly escaped as it would have been via the\n    # CLI mode where the body_format is TEXT\n    #\n\n    aobj = Apprise()\n    aobj.add(\"tgram://1234:aaaaaaaaa/-1123456245134\")\n    assert len(aobj) == 1\n\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT)\n\n    # Test our calls\n    assert mock_post.call_count == 1\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot1234:aaaaaaaaa/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"<b>Test Message Title</b>\\r\\n\"\n        \"Test Message Body &lt;br/&gt; ok&lt;/br&gt;\"\n    )\n\n    # Reset our values\n    mock_post.reset_mock()\n\n    #\n    # Now test that <br/> is correctly escaped if fed as HTML\n    #\n\n    aobj = Apprise()\n    aobj.add(\"tgram://1234:aaaaaaaaa/-1123456245134\")\n    assert len(aobj) == 1\n\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.HTML)\n\n    # Test our calls\n    assert mock_post.call_count == 1\n\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.telegram.org/bot1234:aaaaaaaaa/sendMessage\"\n    )\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"<b>Test Message Title</b>\\r\\nTest Message Body\\r\\nok\\r\\n\"\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_telegram_html_formatting(mock_post):\n    \"\"\"NotifyTelegram() HTML Formatting.\"\"\"\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Simple success response\n    mock_post.return_value.content = dumps({\n        \"ok\": True,\n        \"result\": [\n            {\n                \"update_id\": 645421321,\n                \"message\": {\n                    \"message_id\": 2,\n                    \"from\": {\n                        \"id\": 532389719,\n                        \"is_bot\": False,\n                        \"first_name\": \"Chris\",\n                        \"language_code\": \"en-US\",\n                    },\n                    \"chat\": {\n                        \"id\": 532389719,\n                        \"first_name\": \"Chris\",\n                        \"type\": \"private\",\n                    },\n                    \"date\": 1519694394,\n                    \"text\": \"/start\",\n                    \"entities\": [{\n                        \"offset\": 0,\n                        \"length\": 6,\n                        \"type\": \"bot_command\",\n                    }],\n                },\n            },\n        ],\n    })\n\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/\")\n\n    assert len(aobj) == 1\n\n    assert isinstance(aobj[0], NotifyTelegram)\n\n    # Test our HTML Conversion\n    title = \"<title>&apos;information&apos</title>\"\n    body = (\n        \"<em>&quot;This is in Italic&quot</em><br/>\"\n        \"<h5>&emsp;&emspHeadings&nbsp;are dropped and\"\n        \"&nbspconverted to bold</h5>\"\n    )\n\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.HTML)\n\n    # 1 call to look up bot owner, and second for notification\n    assert mock_post.call_count == 2\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Test that everything is escaped properly in a HTML mode\n    assert (\n        payload[\"text\"]\n        == \"<b>\\r\\n<b>'information'</b>\\r\\n</b>\\r\\n<i>\\\"This is in Italic\\\"\"\n        \"</i>\\r\\n<b>      Headings are dropped and converted to bold</b>\\r\\n\"\n    )\n\n    mock_post.reset_mock()\n\n    assert aobj.notify(title=title, body=body, body_format=NotifyFormat.TEXT)\n\n    # owner has already been looked up, so only one call is made\n    assert mock_post.call_count == 1\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert (\n        payload[\"text\"]\n        == \"<b>&lt;title&gt;&amp;apos;information&amp;apos&lt;/title&gt;</b>\"\n        \"\\r\\n&lt;em&gt;&amp;quot;This is in Italic&amp;quot&lt;/em&gt;&lt;\"\n        \"br/&gt;&lt;h5&gt;&amp;emsp;&amp;emspHeadings&amp;nbsp;are \"\n        \"dropped and&amp;nbspconverted to bold&lt;/h5&gt;\"\n    )\n\n    # Lest test more complex HTML examples now\n    mock_post.reset_mock()\n\n    test_file_01 = os.path.join(TEST_VAR_DIR, \"01_test_example.html\")\n    with open(test_file_01) as html_file:\n        assert aobj.notify(\n            body=html_file.read(), body_format=NotifyFormat.HTML\n        )\n\n    # owner has already been looked up, so only one call is made\n    assert mock_post.call_count == 1\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n    assert (\n        payload[\"text\"]\n        == \"\\r\\n<b>Bootstrap 101 Template</b>\\r\\n<b>My Title</b>\\r\\n\"\n        \"<b>Heading 1</b>\\r\\n-Bullet 1\\r\\n-Bullet 2\\r\\n-Bullet 3\\r\\n\"\n        \"-Bullet 1\\r\\n-Bullet 2\\r\\n-Bullet 3\\r\\n<b>Heading 2</b>\\r\\n\"\n        \"A div entry\\r\\nA div entry\\r\\n\"\n        \"<pre><code class=\\\"language-python\\\">print('hello')</code></pre>\\r\\n\"\n        \"<b>Heading 3</b>\\r\\n<b>Heading 4</b>\\r\\n<b>Heading 5</b>\\r\\n\"\n        \"<b>Heading 6</b>\\r\\nA set of text\\r\\n\"\n        \"Another line after the set of text\\r\\nMore text\\r\\nlabel\"\n    )\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_telegram_threads(mock_post):\n    \"\"\"NotifyTelegram() Threads/Topics.\"\"\"\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Simple success response\n    mock_post.return_value.content = dumps({\n        \"ok\": True,\n        \"result\": [\n            {\n                \"update_id\": 645421321,\n                \"message\": {\n                    \"message_id\": 2,\n                    \"from\": {\n                        \"id\": 532389719,\n                        \"is_bot\": False,\n                        \"first_name\": \"Chris\",\n                        \"language_code\": \"en-US\",\n                    },\n                    \"chat\": {\n                        \"id\": 532389719,\n                        \"first_name\": \"Chris\",\n                        \"type\": \"private\",\n                    },\n                    \"date\": 1519694394,\n                    \"text\": \"/start\",\n                    \"entities\": [{\n                        \"offset\": 0,\n                        \"length\": 6,\n                        \"type\": \"bot_command\",\n                    }],\n                },\n            },\n        ],\n    })\n\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/?thread=1234\")\n\n    assert len(aobj) == 1\n\n    assert isinstance(aobj[0], NotifyTelegram)\n\n    body = \"my threaded message\"\n\n    assert aobj.notify(body=body)\n\n    # 1 call to look up bot owner, and second for notification\n    assert mock_post.call_count == 2\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    assert \"message_thread_id\" in payload\n    assert payload[\"message_thread_id\"] == 1234\n\n    mock_post.reset_mock()\n\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/?topic=1234\")\n\n    assert len(aobj) == 1\n\n    assert isinstance(aobj[0], NotifyTelegram)\n\n    body = \"my message\"\n\n    assert aobj.notify(body=body)\n\n    # 1 call to look up bot owner, and second for notification\n    assert mock_post.call_count == 2\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    assert \"message_thread_id\" in payload\n    assert payload[\"message_thread_id\"] == 1234\n\n    mock_post.reset_mock()\n\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/9876:1234/9876:1111\")\n\n    assert len(aobj) == 1\n\n    assert isinstance(aobj[0], NotifyTelegram)\n\n    body = \"my message\"\n\n    assert aobj.notify(body=body)\n\n    # 1 call to look up bot owner, and second for notification\n    assert mock_post.call_count == 2\n\n    payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n    assert \"message_thread_id\" in payload\n    assert payload[\"message_thread_id\"] == 1111\n\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    assert \"message_thread_id\" in payload\n    assert payload[\"message_thread_id\"] == 1234\n\n    mock_post.reset_mock()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_telegram_markdown_v2(mock_post):\n    \"\"\"NotifyTelegram() MarkdownV2.\"\"\"\n    # Prepare Mock\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n\n    # Simple success response\n    mock_post.return_value.content = dumps({\n        \"ok\": True,\n        \"result\": [\n            {\n                \"update_id\": 645421321,\n                \"message\": {\n                    \"message_id\": 2,\n                    \"from\": {\n                        \"id\": 532389719,\n                        \"is_bot\": False,\n                        \"first_name\": \"Chris\",\n                        \"language_code\": \"en-US\",\n                    },\n                    \"chat\": {\n                        \"id\": 532389719,\n                        \"first_name\": \"Chris\",\n                        \"type\": \"private\",\n                    },\n                    \"date\": 1519694394,\n                    \"text\": \"/start\",\n                    \"entities\": [{\n                        \"offset\": 0,\n                        \"length\": 6,\n                        \"type\": \"bot_command\",\n                    }],\n                },\n            },\n        ],\n    })\n\n    aobj = Apprise()\n    aobj.add(\"tgram://123456789:abcdefg_hijklmnop/?mdv=2&format=markdown\")\n    assert len(aobj) == 1\n    assert isinstance(aobj[0], NotifyTelegram)\n\n    body = \"# my message\\r\\n## more content\\r\\n\\\\# already escaped hashtag\"\n\n    # Test with body format set to markdown\n    assert aobj.notify(body=body, body_format=NotifyFormat.TEXT)\n\n    # 1 call to look up bot owner, and second for notification\n    assert mock_post.call_count == 2\n    payload = loads(mock_post.call_args_list[1][1][\"data\"])\n\n    # Our content is escapped properly\n    assert (\n        payload[\"text\"]\n        == \"\\\\# my message\\r\\n\"\n        \"\\\\#\\\\# more content\\r\\n\\\\# already escaped hashtag\"\n    )\n\n    mock_post.reset_mock()\n\n    # We'll iterate over all of the bad unsupported characters\n    mdv2_unsupported = (\n        \"_\",\n        \"*\",\n        \"[\",\n        \"]\",\n        \"(\",\n        \")\",\n        \"~\",\n        \"`\",\n        \">\",\n        \"#\",\n        \"+\",\n        \"=\",\n        \"|\",\n        \"{\",\n        \"}\",\n        \".\",\n        \"!\",\n        \"-\",\n    )\n\n    for c in mdv2_unsupported:\n        body = f\"bad character: {c}, and already escapped \\\\{c}\"\n\n        # Test with body format set to markdown\n        assert aobj.notify(body=body, body_format=NotifyFormat.TEXT)\n        assert mock_post.call_count == 1\n        payload = loads(mock_post.call_args_list[0][1][\"data\"])\n\n        # Our content is escapped properly\n        assert (\n            payload[\"text\"]\n            == f\"bad character: \\\\{c}, and already escapped \\\\{c}\"\n        )\n\n        mock_post.reset_mock()\n"
  },
  {
    "path": "tests/test_plugin_threema.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.threema import NotifyThreema\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"threema://\",\n        {\n            # No user/secret specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"threema://@:\",\n        {\n            # Invalid url\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"threema://user@secret\",\n        {\n            # gateway id must be 8 characters in len\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"threema://*THEGWID@secret/{targets}/\".format(\n            targets=\"/\".join([\"2222\"])\n        ),\n        {\n            # Invalid target phone number\n            \"instance\": NotifyThreema,\n            \"notify_response\": False,\n            \"privacy_url\": \"threema://%2ATHEGWID@****/2222\",\n        },\n    ),\n    (\n        \"threema://*THEGWID@secret/{targets}/\".format(\n            targets=\"/\".join([\"16134442222\"])\n        ),\n        {\n            # Valid\n            \"instance\": NotifyThreema,\n            \"privacy_url\": \"threema://%2ATHEGWID@****/16134442222\",\n        },\n    ),\n    (\n        \"threema://*THEGWID@secret/{targets}/\".format(\n            targets=\"/\".join([\"16134442222\", \"16134443333\"])\n        ),\n        {\n            # Valid multiple targets\n            \"instance\": NotifyThreema,\n            \"privacy_url\": \"threema://%2ATHEGWID@****/16134442222/16134443333\",\n        },\n    ),\n    (\n        \"threema:///?secret=secret&from=*THEGWID&to={targets}\".format(\n            targets=\",\".join([\"16134448888\", \"user1@gmail.com\", \"abcd1234\"])\n        ),\n        {\n            # Valid\n            \"instance\": NotifyThreema,\n        },\n    ),\n    (\n        \"threema:///?secret=secret&gwid=*THEGWID&to={targets}\".format(\n            targets=\",\".join([\"16134448888\", \"user2@gmail.com\", \"abcd1234\"])\n        ),\n        {\n            # Valid\n            \"instance\": NotifyThreema,\n        },\n    ),\n    (\n        \"threema://*THEGWID@secret\",\n        {\n            \"instance\": NotifyThreema,\n            # No targets specified\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"threema://*THEGWID@secret/16134443333\",\n        {\n            \"instance\": NotifyThreema,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"threema://*THEGWID@secret/16134443333\",\n        {\n            \"instance\": NotifyThreema,\n            # Throws a series of errors\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_threema():\n    \"\"\"NotifyThreema() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_threema_edge_cases(mock_post):\n    \"\"\"NotifyThreema() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    gwid = \"*THEGWID\"\n    secret = \"mysecret\"\n    targets = \"+1 (555) 123-9876\"\n\n    # No email specified\n    with pytest.raises(TypeError):\n        NotifyThreema(user=gwid, secret=None, targets=targets)\n\n    results = NotifyThreema.parse_url(\n        f\"threema://?gwid={gwid}&secret={secret}&to={targets}\"\n    )\n\n    assert isinstance(results, dict)\n    assert results[\"user\"] == gwid\n    assert results[\"secret\"] == secret\n    assert results[\"password\"] is None\n    assert results[\"port\"] is None\n    assert results[\"host\"] == \"\"\n    assert results[\"fullpath\"] == \"/\"\n    assert results[\"path\"] == \"/\"\n    assert results[\"query\"] is None\n    assert results[\"schema\"] == \"threema\"\n    assert results[\"url\"] == \"threema:///\"\n    assert isinstance(results[\"targets\"], list)\n    assert len(results[\"targets\"]) == 1\n    assert results[\"targets\"][0] == targets\n\n    instance = NotifyThreema(**results)\n    assert len(instance.targets) == 1\n    assert instance.targets[0] == (\"phone\", \"15551239876\")\n    assert isinstance(instance, NotifyThreema)\n\n    response = instance.send(title=\"title\", body=\"body 😊\")\n    assert response is True\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"https://msgapi.threema.ch/send_simple\"\n    assert details[1][\"headers\"][\"User-Agent\"] == \"Apprise\"\n    assert details[1][\"headers\"][\"Accept\"] == \"*/*\"\n    assert (\n        details[1][\"headers\"][\"Content-Type\"]\n        == \"application/x-www-form-urlencoded; charset=utf-8\"\n    )\n    assert details[1][\"params\"][\"secret\"] == secret\n    assert details[1][\"params\"][\"from\"] == gwid\n    assert details[1][\"params\"][\"phone\"] == \"15551239876\"\n    assert details[1][\"params\"][\"text\"] == \"body 😊\".encode()\n"
  },
  {
    "path": "tests/test_plugin_title_maxlen.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\nfrom json import loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\n\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.config import ConfigBase\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n\n@pytest.fixture\ndef request_mock(mocker):\n    \"\"\"Prepare requests mock.\"\"\"\n    resp = requests.Request()\n    resp.status_code = requests.codes.ok\n    resp.content = \"\"\n    # json/form/xml plugins now use requests.request();\n    # telegram still uses requests.post()\n    mock_request = mocker.patch(\"requests.request\")\n    mock_request.return_value = resp\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.return_value = resp\n    return mock_request, mock_post\n\n\ndef test_plugin_title_maxlen(request_mock):\n    \"\"\"Plugin title maxlen blending support.\"\"\"\n    mock_request, mock_post = request_mock\n\n    # Load our configuration\n    result, _ = ConfigBase.config_parse_yaml(cleandoc(\"\"\"\n    urls:\n\n      # Our JSON plugin allows for a title definition; we enforce a html format\n      - json://user:pass@example.ca?format=html\n      # Telegram has a title_maxlen of 0\n      - tgram://123456789:AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/987654321\n    \"\"\"))\n\n    # Verify we loaded correctly\n    assert isinstance(result, list)\n    assert len(result) == 2\n    assert len(result[0].tags) == 0\n\n    aobj = Apprise()\n    aobj.add(result)\n    assert len(aobj) == 2\n\n    title = \"Hello World\"\n    body = \"Foo Bar\"\n    assert aobj.notify(title=title, body=body)\n\n    # JSON uses requests.request, Telegram uses requests.post\n    assert mock_request.call_count == 1\n    assert mock_post.call_count == 1\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    assert details[0][1] == \"http://example.ca\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"message\"] == body\n    assert payload[\"title\"] == \"Hello World\"\n\n    # Telegram plugin: requests.post(url, data=...) → url is [0][0]\n    details = mock_post.call_args_list[0]\n    assert (\n        details[0][0]\n        == \"https://api.telegram.org/bot123456789:\"\n        \"AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/sendMessage\"\n    )\n    payload = loads(details[1][\"data\"])\n    # HTML in Title is escaped\n    assert payload[\"text\"] == \"<b>Hello World</b>\\r\\nFoo Bar\"\n\n    # Reset our mock objects\n    mock_request.reset_mock()\n    mock_post.reset_mock()\n    #\n    # Reverse the configuration file and expect the same results\n    #\n    result, _config = ConfigBase.config_parse_yaml(cleandoc(\"\"\"\n    urls:\n\n      # Telegram has a title_maxlen of 0\n      - tgram://123456789:AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/987654321\n      # Our JSON plugin allows for a title definition; we enforce a html format\n      - json://user:pass@example.ca?format=html\n    \"\"\"))\n\n    # Verify we loaded correctly\n    assert isinstance(result, list)\n    assert len(result) == 2\n    assert len(result[0].tags) == 0\n\n    aobj = Apprise()\n    aobj.add(result)\n    assert len(aobj) == 2\n\n    title = \"Hello World\"\n    body = \"Foo Bar\"\n    assert aobj.notify(title=title, body=body)\n\n    # JSON uses requests.request, Telegram uses requests.post\n    assert mock_request.call_count == 1\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert (\n        details[0][0]\n        == \"https://api.telegram.org/bot123456789:\"\n        \"AABCeFGhIJKLmnOPqrStUvWxYZ12345678U/sendMessage\"\n    )\n    payload = loads(details[1][\"data\"])\n    # HTML in Title is escaped\n    assert payload[\"text\"] == \"<b>Hello World</b>\\r\\nFoo Bar\"\n\n    details = mock_request.call_args_list[0]\n    assert details[0][0] == \"POST\"\n    assert details[0][1] == \"http://example.ca\"\n    payload = loads(details[1][\"data\"])\n    assert payload[\"message\"] == body\n    assert payload[\"title\"] == \"Hello World\"\n"
  },
  {
    "path": "tests/test_plugin_twilio.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.twilio import NotifyTwilio, TwilioNotificationMethod\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"twilio://\",\n        {\n            # No Account SID specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://:@/\",\n        {\n            # invalid Auth token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://AC{}@12345678\".format(\"a\" * 32),\n        {\n            # Just sid provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@_\".format(\"a\" * 32, \"b\" * 32),\n        {\n            # sid and token provided but invalid from\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"3\" * 5),\n        {\n            # using short-code (5 characters) without a target\n            # We can still instantiate ourselves with a valid short code\n            \"instance\": NotifyTwilio,\n            # Since there are no targets specified we expect a False return on\n            # send()\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"3\" * 9),\n        {\n            # sid and token provided and from but invalid from no\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}/123/{}/abcd/w:{}\".format(\n            \"a\" * 32, \"b\" * 32, \"3\" * 11, \"9\" * 15, 8 * 11\n        ),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@12345/{}\".format(\"a\" * 32, \"b\" * 32, \"4\" * 11),\n        {\n            # using short-code (5 characters)\n            \"instance\": NotifyTwilio,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"twilio://...aaaa:b...b@12345\",\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@98765/{}/w:{}/\".format(\n            \"a\" * 32, \"b\" * 32, \"4\" * 11, \"5\" * 11\n        ),\n        {\n            # using short-code (5 characters) and 1 twillio address ignored\n            # because source phone number can not be a short code\n            \"instance\": NotifyTwilio,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"twilio://...aaaa:b...b@98765\",\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@w:12345/{}/{}\".format(\n            \"a\" * 32, \"b\" * 32, \"4\" * 11, \"5\" * 11\n        ),\n        {\n            # Invalid short-code\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@123456/{}\".format(\"a\" * 32, \"b\" * 32, \"4\" * 11),\n        {\n            # using short-code (6 characters)\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"5\" * 11),\n        {\n            # using phone no with no target - we text ourselves in\n            # this case\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}?method=sms\".format(\"a\" * 32, \"b\" * 32, \"5\" * 11),\n        {\n            # Specify explicitly notification method sms\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}?method=mms\".format(\"a\" * 32, \"b\" * 32, \"5\" * 11),\n        {\n            # Invalid notification method\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}?method=call\".format(\n            \"a\" * 32, \"b\" * 32, \"w:\" + \"5\" * 11\n        ),\n        {\n            # Incompatibility between Whatsapp mode and CALL method\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twilio://_?sid=AC{}&token={}&from={}\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://_?sid=AC{}&token={}&from={}&to=w:{}\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            # Support whatsapp (w: before number)\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://_?sid=AC{}&token={}&source={}\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing (use source instead of\n            # from)\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://_?sid=AC{}&token={}&from={}&to={}\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11, \"7\" * 13\n        ),\n        {\n            # use to=\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://_?sid=AC{}&token={}&from={}&to={}method=call\".format(\n            \"a\" * 32, \"b\" * 32, \"5\" * 11, \"7\" * 13\n        ),\n        {\n            # Specify notification method call\n            \"instance\": NotifyTwilio,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"6\" * 11),\n        {\n            \"instance\": NotifyTwilio,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"twilio://AC{}:{}@{}\".format(\"a\" * 32, \"b\" * 32, \"6\" * 11),\n        {\n            \"instance\": NotifyTwilio,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_twilio_urls():\n    \"\"\"NotifyTwilio() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_twilio_auth(mock_post):\n    \"\"\"NotifyTwilio() Auth.\n\n    - account-wide auth token\n    - API key and its own auth token\n    \"\"\"\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    account_sid = \"AC{}\".format(\"b\" * 32)\n    apikey = \"SK{}\".format(\"b\" * 32)\n    auth_token = \"{}\".format(\"b\" * 32)\n    source = \"+1 (555) 123-3456\"\n    dest = \"+1 (555) 987-6543\"\n    message_contents = \"test\"\n\n    # Variation of initialization without API key\n    obj = Apprise.instantiate(\n        f\"twilio://{account_sid}:{auth_token}@{source}/{dest}\"\n    )\n    assert isinstance(obj, NotifyTwilio)\n    assert isinstance(obj.url(), str)\n\n    # Send Notification\n    assert obj.send(body=message_contents) is True\n\n    # Variation of initialization with API key\n    obj = Apprise.instantiate(\n        f\"twilio://{account_sid}:{auth_token}@{source}/{dest}?apikey={apikey}\"\n    )\n    assert isinstance(obj, NotifyTwilio)\n    assert isinstance(obj.url(), str)\n\n    # Send Notification\n    assert obj.send(body=message_contents) is True\n\n    # Variation of initialization with method call\n    obj = Apprise.instantiate(\n        f\"twilio://{account_sid}:{auth_token}@{source}/{dest}?method=call\"\n    )\n    assert isinstance(obj, NotifyTwilio)\n    assert isinstance(obj.url(), str)\n\n    # Send Notification\n    assert obj.send(body=message_contents) is True\n\n    # Validate expected call parameters\n    assert mock_post.call_count == 3\n    first_call = mock_post.call_args_list[0]\n    second_call = mock_post.call_args_list[1]\n    third_call = mock_post.call_args_list[2]\n\n    # URL and message parameters are the same for both calls\n    assert (\n        first_call[0][0]\n        == second_call[0][0]\n        == (\"https://api.twilio.com/2010-04-01/Accounts\"\n            f\"/{account_sid}/Messages.json\")\n    )\n    assert (\n        first_call[1][\"data\"][\"Body\"]\n        == second_call[1][\"data\"][\"Body\"]\n        == message_contents\n    )\n    assert (\n        third_call[0][0]\n        == (\"https://api.twilio.com/2010-04-01/Accounts\"\n        f\"/{account_sid}/Calls.json\")\n    )\n    assert (\n        third_call[1][\"data\"][\"Twiml\"]\n        == message_contents\n    )\n    assert (\n        first_call[1][\"data\"][\"From\"]\n        == second_call[1][\"data\"][\"From\"]\n        == third_call[1][\"data\"][\"From\"]\n        == \"+15551233456\"\n    )\n    assert (\n        first_call[1][\"data\"][\"To\"]\n        == second_call[1][\"data\"][\"To\"]\n        == third_call[1][\"data\"][\"To\"]\n        == \"+15559876543\"\n    )\n\n    # Auth differs depending on if API Key is used\n    assert first_call[1][\"auth\"] == (account_sid, auth_token)\n    assert second_call[1][\"auth\"] == (apikey, auth_token)\n    assert third_call[1][\"auth\"] == (account_sid, auth_token)\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_twilio_edge_cases(mock_post):\n    \"\"\"NotifyTwilio() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    account_sid = \"AC{}\".format(\"b\" * 32)\n    auth_token = \"{}\".format(\"b\" * 32)\n    source = \"+1 (555) 123-3456\"\n    whatsapp_source = \"w:\" + \"+1 (555) 123-3456\"\n\n    # No account_sid specified\n    with pytest.raises(TypeError):\n        NotifyTwilio(account_sid=None, auth_token=auth_token, source=source)\n\n    # No auth_token specified\n    with pytest.raises(TypeError):\n        NotifyTwilio(account_sid=account_sid, auth_token=None, source=source)\n\n    # Source is bad\n    with pytest.raises(TypeError):\n        NotifyTwilio(account_sid=account_sid, auth_token=auth_token, source=\"\")\n\n    # Incompatibility between mode and method\n    with pytest.raises(TypeError):\n        NotifyTwilio(\n            account_sid=account_sid, auth_token=auth_token,\n            source=whatsapp_source, method=TwilioNotificationMethod.CALL\n        )\n\n    # a error response\n    response.status_code = 400\n    response.content = dumps({\n        \"code\": 21211,\n        \"message\": \"The 'To' number +1234567 is not a valid phone number.\",\n    })\n    mock_post.return_value = response\n\n    # Initialize our object\n    obj = NotifyTwilio(\n        account_sid=account_sid, auth_token=auth_token, source=source\n    )\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n"
  },
  {
    "path": "tests/test_plugin_twist.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.twist import NotifyTwist\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"twist://\",\n        {\n            # Missing Email and Login\n            \"instance\": None,\n        },\n    ),\n    (\n        \"twist://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"twist://user@example.com/\",\n        {\n            # No password\n            \"instance\": None,\n        },\n    ),\n    (\n        \"twist://user@example.com/password\",\n        {\n            # Password acceptable as first entry in path\n            \"instance\": NotifyTwist,\n            # Expected notify() response is False because internally we would\n            # have failed to login\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"twist://password:user1@example.com\",\n        {\n            # password:login acceptable\n            \"instance\": NotifyTwist,\n            # Expected notify() response is False because internally we would\n            # have failed to login\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"twist://****:user1@example.com\",\n        },\n    ),\n    (\n        \"twist://password:user2@example.com\",\n        {\n            # password:login acceptable\n            \"instance\": NotifyTwist,\n            # Expected notify() response is False because internally we would\n            # have logged in, but we would have failed to look up the #General\n            # channel and workspace.\n            \"requests_response_text\": {\n                # Login expected response\n                \"id\": 1234,\n                \"default_workspace\": 9876,\n            },\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"twist://password:user2@example.com\",\n        {\n            \"instance\": NotifyTwist,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"twist://password:user2@example.com\",\n        {\n            \"instance\": NotifyTwist,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_twist_urls():\n    \"\"\"NotifyTwist() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_twist_init():\n    \"\"\"NotifyTwist() init()\"\"\"\n    with pytest.raises(TypeError):\n        NotifyTwist(email=\"invalid\", targets=None)\n\n    with pytest.raises(TypeError):\n        NotifyTwist(email=\"user@domain\", targets=None)\n\n    # Simple object initialization\n    result = NotifyTwist(\n        password=\"abc123\", email=\"user@domain.com\", targets=None\n    )\n    assert result.user == \"user\"\n    assert result.host == \"domain.com\"\n    assert result.password == \"abc123\"\n\n    # Channel Instantiation by name\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Channel Instantiation by id (faster if you know the translation)\n    obj = Apprise.instantiate(\"twist://password:user@example.com/12345\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Invalid Channel - (max characters is 64), the below drops it\n    obj = Apprise.instantiate(\n        \"twist://password:user@example.com/{}\".format(\"a\" * 65)\n    )\n    assert isinstance(obj, NotifyTwist)\n\n    # No User detect\n    result = NotifyTwist.parse_url(\"twist://example.com\")\n    assert result is None\n\n    # test usage of to=\n    result = NotifyTwist.parse_url(\n        \"twist://password:user@example.com?to=#channel\"\n    )\n    assert isinstance(result, dict)\n    assert \"user\" in result\n    assert result[\"user\"] == \"user\"\n    assert \"host\" in result\n    assert result[\"host\"] == \"example.com\"\n    assert \"password\" in result\n    assert result[\"password\"] == \"password\"\n    assert \"targets\" in result\n    assert isinstance(result[\"targets\"], list) is True\n    assert len(result[\"targets\"]) == 1\n    assert \"#channel\" in result[\"targets\"]\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_twist_auth(mock_post, mock_get):\n    \"\"\"NotifyTwist() login/logout()\"\"\"\n\n    # Prepare Mock\n    mock_get.return_value = requests.Request()\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n    mock_post.return_value.content = dumps({\n        \"token\": \"2e82c1e4e8b0091fdaa34ff3972351821406f796\",\n        \"default_workspace\": 12345,\n    })\n    mock_get.return_value.content = mock_post.return_value.content\n\n    # Instantiate an object\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel\")\n    assert isinstance(obj, NotifyTwist)\n    # not logged in yet\n    obj.logout()\n    assert obj.login() is True\n\n    # Clear our channel listing\n    obj.channels.clear()\n    # No channels mean there is no internal migration/lookups required\n    assert obj._channel_migration() is True\n\n    # Workspace Success\n    mock_post.return_value.content = dumps([\n        {\n            \"name\": \"TesT\",\n            \"id\": 1,\n        },\n        {\n            \"name\": \"tESt2\",\n            \"id\": 2,\n        },\n    ])\n    mock_get.return_value.content = mock_post.return_value.content\n\n    results = obj.get_workspaces()\n    assert len(results) == 2\n    assert \"test\" in results\n    assert results[\"test\"] == 1\n    assert \"test2\" in results\n    assert results[\"test2\"] == 2\n\n    mock_post.return_value.content = dumps([\n        {\n            \"name\": \"ChaNNEL1\",\n            \"id\": 1,\n        },\n        {\n            \"name\": \"chaNNel2\",\n            \"id\": 2,\n        },\n    ])\n    mock_get.return_value.content = mock_post.return_value.content\n    results = obj.get_channels(wid=1)\n    assert len(results) == 2\n    assert \"channel1\" in results\n    assert results[\"channel1\"] == 1\n    assert \"channel2\" in results\n    assert results[\"channel2\"] == 2\n\n    # Test result failure response\n    mock_post.return_value.status_code = 403\n    mock_get.return_value.status_code = 403\n    assert obj.get_workspaces() == {}\n\n    # Return things how they were\n    mock_post.return_value.status_code = requests.codes.ok\n    mock_get.return_value.status_code = requests.codes.ok\n\n    # Forces call to logout:\n    del obj\n\n    #\n    # Authentication failures\n    #\n    mock_post.return_value.status_code = 403\n    mock_get.return_value.status_code = 403\n\n    # Instantiate an object\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Authentication failed\n    assert obj.get_workspaces() == {}\n    assert obj.get_channels(wid=1) == {}\n    assert obj._channel_migration() is False\n    assert obj.send(\"body\", \"title\") is False\n\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Calling logout on an object already logged out\n    obj.logout()\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_twist_cache(mock_post, mock_get):\n    \"\"\"NotifyTwist() Cache Handling.\"\"\"\n\n    def _response(url, *args, **kwargs):\n\n        # Default configuration\n        request = mock.Mock()\n        request.status_code = requests.codes.ok\n        request.content = \"{}\"\n\n        if url.endswith(\"/login\"):\n            # Simulate a successful login\n            request.content = dumps({\n                \"token\": \"2e82c1e4e8b0091fdaa34ff3972351821406f796\",\n                \"default_workspace\": 1,\n            })\n\n        elif url.endswith(\"workspaces/get\"):\n            request.content = dumps([\n                {\n                    \"name\": \"TeamA\",\n                    \"id\": 1,\n                },\n                {\n                    \"name\": \"TeamB\",\n                    \"id\": 2,\n                },\n            ])\n\n        elif url.endswith(\"channels/get\"):\n            request.content = dumps([\n                {\n                    \"name\": \"ChanA\",\n                    \"id\": 1,\n                },\n                {\n                    \"name\": \"ChanB\",\n                    \"id\": 2,\n                },\n            ])\n\n        return request\n\n    mock_get.side_effect = _response\n    mock_post.side_effect = _response\n\n    # Instantiate an object\n    obj = Apprise.instantiate(\n        \"twist://password:user@example.com/\"\n        \"#ChanB/1:1/TeamA:ChanA/Ignore:Chan/3:1\"\n    )\n    assert isinstance(obj, NotifyTwist)\n\n    # Will detect channels except Ignore:Chan\n    assert obj._channel_migration() is False\n\n    # Add another channel\n    obj.channels.add(\"ChanB\")\n    assert obj._channel_migration() is True\n\n    # Nothing more to detect the second time around\n    assert obj._channel_migration() is True\n\n    # Send a notification\n    assert obj.send(\"body\", \"title\") is True\n\n    def _can_not_send_response(url, *args, **kwargs):\n        \"\"\"Simulate a case where we can't send a notification.\"\"\"\n        # Force a failure\n        request = mock.Mock()\n        request.status_code = 403\n        request.content = \"{}\"\n        return request\n\n    mock_get.side_effect = _can_not_send_response\n    mock_post.side_effect = _can_not_send_response\n\n    # Send a notification and fail at it\n    assert obj.send(\"body\", \"title\") is False\n\n\n@mock.patch(\"requests.get\")\n@mock.patch(\"requests.post\")\ndef test_plugin_twist_fetch(mock_post, mock_get):\n    \"\"\"NotifyTwist() fetch()\n\n    fetch() is a wrapper that handles all kinds of edge cases and even attempts\n    to re-authenticate to the Twist server if our token happens to expire.\n    This tests these edge cases\n    \"\"\"\n\n    # Track our iteration; by tracing within an object, we can re-reference\n    # it within a function scope.\n    cache = {\n        \"first_time\": True,\n    }\n\n    def _reauth_response(url, *args, **kwargs):\n        \"\"\"Tests re-authentication process and then a successful retry.\"\"\"\n\n        # Default configuration\n        request = mock.Mock()\n        request.status_code = requests.codes.ok\n\n        # Simulate a successful login\n        request.content = dumps({\n            \"token\": \"2e82c1e4e8b0091fdaa34ff3972351821406f796\",\n            \"default_workspace\": 12345,\n        })\n\n        if url.endswith(\"threads/add\") and cache[\"first_time\"] is True:\n            # First time iteration; act as if we failed; our second iteration\n            # will not enter this and be successful. This is done by simply\n            # toggling the first_time flag:\n            cache[\"first_time\"] = False\n\n            # otherwise, we set our first-time failure settings\n            request.status_code = 403\n            request.content = dumps({\n                \"error_code\": 200,\n                \"error_string\": \"Invalid token\",\n            })\n\n        return request\n\n    mock_get.side_effect = _reauth_response\n    mock_post.side_effect = _reauth_response\n\n    # Instantiate an object\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel/34\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Simulate a re-authentication\n    postokay, response = obj._fetch(\"threads/add\")\n\n    ##########################################################################\n    cache = {\n        \"first_time\": True,\n    }\n\n    def _reauth_exception_response(url, *args, **kwargs):\n        \"\"\"Tests exception thrown after re-authentication process.\"\"\"\n\n        # Default configuration\n        request = mock.Mock()\n        request.status_code = requests.codes.ok\n\n        # Simulate a successful login\n        request.content = dumps({\n            \"token\": \"2e82c1e4e8b0091fdaa34ff3972351821406f796\",\n            \"default_workspace\": 12345,\n        })\n\n        if url.endswith(\"threads/add\") and cache[\"first_time\"] is True:\n            # First time iteration; act as if we failed; our second iteration\n            # will not enter this and be successful. This is done by simply\n            # toggling the first_time flag:\n            cache[\"first_time\"] = False\n\n            # otherwise, we set our first-time failure settings\n            request.status_code = 403\n            request.content = dumps({\n                \"error_code\": 200,\n                \"error_string\": \"Invalid token\",\n            })\n\n        elif url.endswith(\"threads/add\") and cache[\"first_time\"] is False:\n            # unparseable response throws the exception\n            request.status_code = 200\n            request.content = \"{\"\n\n        return request\n\n    mock_get.side_effect = _reauth_exception_response\n    mock_post.side_effect = _reauth_exception_response\n\n    # Instantiate an object\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel/34\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Simulate a re-authentication\n    postokay, response = obj._fetch(\"threads/add\")\n\n    ##########################################################################\n    cache = {\n        \"first_time\": True,\n    }\n\n    def _reauth_failed_response(url, *args, **kwargs):\n        \"\"\"Tests re-authentication process and have it not succeed.\"\"\"\n\n        # Default configuration\n        request = mock.Mock()\n        request.status_code = requests.codes.ok\n\n        # Simulate a successful login\n        request.content = dumps({\n            \"token\": \"2e82c1e4e8b0091fdaa34ff3972351821406f796\",\n            \"default_workspace\": 12345,\n        })\n\n        if url.endswith(\"threads/add\") and cache[\"first_time\"] is True:\n            # First time iteration; act as if we failed; our second iteration\n            # will not enter this and be successful. This is done by simply\n            # toggling the first_time flag:\n            cache[\"first_time\"] = False\n\n            # otherwise, we set our first-time failure settings\n            request.status_code = 403\n            request.content = dumps({\n                \"error_code\": 200,\n                \"error_string\": \"Invalid token\",\n            })\n\n        elif url.endswith(\"/login\") and cache[\"first_time\"] is False:\n            # Fail to login\n            request.status_code = 403\n            request.content = \"{}\"\n\n        return request\n\n    mock_get.side_effect = _reauth_failed_response\n    mock_post.side_effect = _reauth_failed_response\n\n    # Instantiate an object\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel/34\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Simulate a re-authentication\n    postokay, response = obj._fetch(\"threads/add\")\n\n    def _unparseable_json_response(url, *args, **kwargs):\n\n        # Default configuration\n        request = mock.Mock()\n        request.status_code = requests.codes.ok\n        request.content = \"{\"\n        return request\n\n    mock_get.side_effect = _unparseable_json_response\n    mock_post.side_effect = _unparseable_json_response\n\n    # Instantiate our object\n    obj = Apprise.instantiate(\"twist://password:user@example.com/#Channel/34\")\n    assert isinstance(obj, NotifyTwist)\n\n    # Simulate a re-authentication\n    postokay, response = obj._fetch(\"threads/add\")\n    assert postokay is True\n    # When we can't parse the content, we still default to an empty\n    # dictionary\n    assert response == {}\n"
  },
  {
    "path": "tests/test_plugin_twitter.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom datetime import datetime, timezone\nimport json\nimport logging\nimport os\nfrom unittest.mock import Mock, patch\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseAttachment, NotifyType\nfrom apprise.plugins.twitter import NotifyTwitter\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\nTWITTER_SCREEN_NAME = \"apprise\"\n\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyTwitter\n    ##################################\n    (\n        \"twitter://\",\n        {\n            # Missing Consumer API Key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twitter://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twitter://consumer_key\",\n        {\n            # Missing Keys\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twitter://consumer_key/consumer_secret/\",\n        {\n            # Missing Keys\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twitter://consumer_key/consumer_secret/atoken1/\",\n        {\n            # Missing Access Secret\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"twitter://consumer_key/consumer_secret/atoken2/access_secret\",\n        {\n            # No user mean's we message ourselves\n            \"instance\": NotifyTwitter,\n            # Expected notify() response False (because we won't be able\n            # to detect our user)\n            \"notify_response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"x://c...y/****/a...2/****\",\n        },\n    ),\n    (\n        (\n            \"twitter://consumer_key/consumer_secret/atoken3/access_secret\"\n            \"?cache=no\"\n        ),\n        {\n            # No user mean's we message ourselves\n            \"instance\": NotifyTwitter,\n            # However we'll be okay if we return a proper response\n            \"requests_response_text\": {\n                \"id\": 12345,\n                \"screen_name\": \"test\",\n                # For attachment handling\n                \"media_id\": 123,\n            },\n        },\n    ),\n    (\n        \"twitter://consumer_key/consumer_secret/atoken4/access_secret\",\n        {\n            # No user mean's we message ourselves\n            \"instance\": NotifyTwitter,\n            # However we'll be okay if we return a proper response\n            \"requests_response_text\": {\n                \"id\": 12345,\n                \"screen_name\": \"test\",\n                # For attachment handling\n                \"media_id\": 123,\n            },\n        },\n    ),\n    # A duplicate of the entry above, this will cause cache to be referenced\n    (\n        \"twitter://consumer_key/consumer_secret/atoken5/access_secret\",\n        {\n            # No user mean's we message ourselves\n            \"instance\": NotifyTwitter,\n            # However we'll be okay if we return a proper response\n            \"requests_response_text\": {\n                \"id\": 12345,\n                \"screen_name\": \"test\",\n                # For attachment handling\n                \"media_id\": 123,\n            },\n        },\n    ),\n    # handle cases where the screen_name is missing from the response causing\n    # an exception during parsing\n    (\n        \"twitter://consumer_key/consumer_secret2/atoken6/access_secret\",\n        {\n            # No user mean's we message ourselves\n            \"instance\": NotifyTwitter,\n            # However we'll be okay if we return a proper response\n            \"requests_response_text\": {\n                \"id\": 12345,\n                # For attachment handling\n                \"media_id\": 123,\n            },\n            # due to a mangled response_text we'll fail\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"twitter://user@consumer_key/csecret2/atoken7/access_secret/-/%/\",\n        {\n            # One Invalid User\n            \"instance\": NotifyTwitter,\n            # Expected notify() response False (because we won't be able\n            # to detect our user)\n            \"notify_response\": False,\n        },\n    ),\n    (\n        (\n            \"twitter://user@consumer_key/csecret/atoken8/access_secret\"\n            \"?cache=No&batch=No\"\n        ),\n        {\n            # No Cache & No Batch\n            \"instance\": NotifyTwitter,\n            \"requests_response_text\": [{\"id\": 12345, \"screen_name\": \"user\"}],\n        },\n    ),\n    (\n        \"twitter://user@consumer_key/csecret/atoken9/access_secret\",\n        {\n            # We're good!\n            \"instance\": NotifyTwitter,\n            \"requests_response_text\": [{\"id\": 12345, \"screen_name\": \"user\"}],\n        },\n    ),\n    (\n        \"twitter://user@consumer_key/csecret/atoken11/access_secret\",\n        {\n            # We're identifying the same user we already sent to\n            \"instance\": NotifyTwitter,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"tweet://ckey/csecret/atoken12/access_secret\",\n        {\n            # A Public Tweet\n            \"instance\": NotifyTwitter,\n        },\n    ),\n    (\n        \"twitter://user@ckey/csecret/atoken13/access_secret?mode=invalid\",\n        {\n            # An invalid mode\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        (\n            \"twitter://usera@consumer_key/consumer_secret/atoken14/\"\n            \"access_secret/user/?to=userb\"\n        ),\n        {\n            # We're good!\n            \"instance\": NotifyTwitter,\n            \"requests_response_text\": [\n                {\"id\": 12345, \"screen_name\": \"usera\"},\n                {\"id\": 12346, \"screen_name\": \"userb\"},\n                {\n                    # A garbage entry we can test exception handling on\n                    \"id\": 123,\n                },\n            ],\n        },\n    ),\n    (\n        \"twitter://ckey/csecret/atoken15/access_secret\",\n        {\n            \"instance\": NotifyTwitter,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"twitter://ckey/csecret/atoken16/access_secret\",\n        {\n            \"instance\": NotifyTwitter,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    (\n        \"twitter://ckey/csecret/atoken17/access_secret?mode=tweet\",\n        {\n            \"instance\": NotifyTwitter,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef good_response(data):\n    \"\"\"Prepare a good response.\"\"\"\n    response = Mock()\n    response.content = json.dumps(data)\n    response.status_code = requests.codes.ok\n    return response\n\n\ndef bad_response(data):\n    \"\"\"Prepare a bad response.\"\"\"\n    response = Mock()\n    response.content = json.dumps(data)\n    response.status_code = requests.codes.internal_server_error\n    return response\n\n\n@pytest.fixture\ndef twitter_url():\n    ckey = \"ckey\"\n    csecret = \"csecret\"\n    akey = \"akey\"\n    asecret = \"asecret\"\n    url = f\"twitter://{ckey}/{csecret}/{akey}/{asecret}\"\n    return url\n\n\n@pytest.fixture\ndef good_message_response():\n    \"\"\"Prepare a good tweet response.\"\"\"\n    response = good_response({\n        \"screen_name\": TWITTER_SCREEN_NAME,\n        \"id\": 9876,\n    })\n    return response\n\n\n@pytest.fixture\ndef bad_message_response():\n    \"\"\"Prepare a bad message response.\"\"\"\n    response = bad_response(\n        {\n            \"errors\": [{\n                \"code\": 999,\n                \"message\": \"Something failed\",\n            }]\n        }\n    )\n    return response\n\n\n@pytest.fixture\ndef good_media_response():\n    \"\"\"Prepare a good media response.\"\"\"\n    response = Mock()\n    response.content = json.dumps({\n        \"media_id\": 710511363345354753,\n        \"media_id_string\": \"710511363345354753\",\n        \"media_key\": \"3_710511363345354753\",\n        \"size\": 11065,\n        \"expires_after_secs\": 86400,\n        \"image\": {\"image_type\": \"image/jpeg\", \"w\": 800, \"h\": 320},\n    })\n    response.status_code = requests.codes.ok\n    return response\n\n\n@pytest.fixture\ndef bad_media_response():\n    \"\"\"Prepare a bad media response.\"\"\"\n    response = bad_response({\n        \"errors\": [{\n            \"code\": 93,\n            \"message\": (\n                \"This application is not allowed to access or \"\n                \"delete your direct messages.\"\n            ),\n        }]\n    })\n    return response\n\n\n@pytest.fixture(autouse=True)\ndef ensure_get_verify_credentials_is_mocked(mocker, good_message_response):\n    \"\"\"\n    Make sure requests to https://api.twitter.com/1.1/account/verify_credentials.json\n    do not escape the test harness, for all test case functions.\n    \"\"\"\n    mock_get = mocker.patch(\"requests.get\")\n    mock_get.return_value = good_message_response\n\n\ndef test_plugin_twitter_urls():\n    \"\"\"NotifyTwitter() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_twitter_general(mocker):\n    \"\"\"NotifyTwitter() General Tests.\"\"\"\n\n    mock_get = mocker.patch(\"requests.get\")\n    mock_post = mocker.patch(\"requests.post\")\n\n    ckey = \"ckey\"\n    csecret = \"csecret\"\n    akey = \"akey\"\n    asecret = \"asecret\"\n\n    response_obj = [{\n        \"screen_name\": TWITTER_SCREEN_NAME,\n        \"id\": 9876,\n    }]\n\n    # Epoch time:\n    epoch = datetime.fromtimestamp(0, timezone.utc)\n\n    request = Mock()\n    request.content = json.dumps(response_obj)\n    request.status_code = requests.codes.ok\n    request.headers = {\n        \"x-rate-limit-reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"x-rate-limit-remaining\": 1,\n    }\n\n    # Prepare Mock\n    mock_get.return_value = request\n    mock_post.return_value = request\n\n    # Variation Initializations\n    obj = NotifyTwitter(\n        ckey=ckey,\n        csecret=csecret,\n        akey=akey,\n        asecret=asecret,\n        targets=TWITTER_SCREEN_NAME,\n    )\n\n    assert isinstance(obj, NotifyTwitter) is True\n    assert isinstance(obj.url(), str) is True\n\n    # apprise room was found\n    assert obj.send(body=\"test\") is True\n\n    # Change our status code and try again\n    request.status_code = 403\n    assert obj.send(body=\"test\") is False\n    assert obj.ratelimit_remaining == 1\n\n    # Return the status\n    request.status_code = requests.codes.ok\n    # Force a reset\n    request.headers[\"x-rate-limit-remaining\"] = 0\n    # behind the scenes, it should cause us to update our rate limit\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 0\n\n    # This should cause us to block\n    request.headers[\"x-rate-limit-remaining\"] = 10\n    assert obj.send(body=\"test\") is True\n    assert obj.ratelimit_remaining == 10\n\n    # Handle cases where we simply couldn't get this field\n    del request.headers[\"x-rate-limit-remaining\"]\n    assert obj.send(body=\"test\") is True\n    # It remains set to the last value\n    assert obj.ratelimit_remaining == 10\n\n    # Reset our variable back to 1\n    request.headers[\"x-rate-limit-remaining\"] = 1\n\n    # Handle cases where our epoch time is wrong\n    del request.headers[\"x-rate-limit-reset\"]\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    request.headers[\"x-rate-limit-reset\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds() + 1\n    request.headers[\"x-rate-limit-remaining\"] = 0\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Return our object, but place it in the future forcing us to block\n    request.headers[\"x-rate-limit-reset\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds() - 1\n    request.headers[\"x-rate-limit-remaining\"] = 0\n    obj.ratelimit_remaining = 0\n    assert obj.send(body=\"test\") is True\n\n    # Return our limits to always work\n    request.headers[\"x-rate-limit-reset\"] = (\n        datetime.now(timezone.utc) - epoch\n    ).total_seconds()\n    request.headers[\"x-rate-limit-remaining\"] = 1\n    obj.ratelimit_remaining = 1\n\n    # Alter pending targets\n    obj.targets.append(\"usera\")\n    request.content = json.dumps(response_obj)\n    response_obj = [{\n        \"screen_name\": \"usera\",\n        \"id\": 1234,\n    }]\n\n    assert obj.send(body=\"test\") is True\n\n    # Flush our cache forcing it is re-creating\n    NotifyTwitter._user_cache = {}\n    assert obj.send(body=\"test\") is True\n\n    # Cause content response to be None\n    request.content = None\n    assert obj.send(body=\"test\") is True\n\n    # Invalid JSON\n    request.content = \"{\"\n    assert obj.send(body=\"test\") is True\n\n    # Return it to a parseable string\n    request.content = \"{}\"\n\n    results = NotifyTwitter.parse_url(\n        f\"twitter://{ckey}/{csecret}/{akey}/{asecret}?to={TWITTER_SCREEN_NAME}\"\n    )\n    assert isinstance(results, dict) is True\n    assert TWITTER_SCREEN_NAME in results[\"targets\"]\n\n    # cause a json parsing issue now\n    response_obj = None\n    assert obj.send(body=\"test\") is True\n\n    response_obj = \"{\"\n    assert obj.send(body=\"test\") is True\n\n    # Set ourselves up to handle whoami calls\n\n    # Flush out our cache\n    NotifyTwitter._user_cache = {}\n\n    response_obj = {\n        \"screen_name\": TWITTER_SCREEN_NAME,\n        \"id\": 9876,\n    }\n    request.content = json.dumps(response_obj)\n\n    obj = NotifyTwitter(ckey=ckey, csecret=csecret, akey=akey, asecret=asecret)\n\n    assert obj.send(body=\"test\") is True\n\n    # Alter the key forcing us to look up a new value of ourselves again\n    NotifyTwitter._user_cache = {}\n    NotifyTwitter._whoami_cache = None\n    obj.ckey = \"different.then.it.was\"\n    assert obj.send(body=\"test\") is True\n\n    NotifyTwitter._whoami_cache = None\n    obj.ckey = \"different.again\"\n    assert obj.send(body=\"test\") is True\n\n\ndef test_plugin_twitter_edge_cases():\n    \"\"\"NotifyTwitter() Edge Cases.\"\"\"\n\n    with pytest.raises(TypeError):\n        NotifyTwitter(ckey=None, csecret=None, akey=None, asecret=None)\n\n    with pytest.raises(TypeError):\n        NotifyTwitter(ckey=\"value\", csecret=None, akey=None, asecret=None)\n\n    with pytest.raises(TypeError):\n        NotifyTwitter(ckey=\"value\", csecret=\"value\", akey=None, asecret=None)\n\n    with pytest.raises(TypeError):\n        NotifyTwitter(\n            ckey=\"value\", csecret=\"value\", akey=\"value\", asecret=None\n        )\n\n    assert isinstance(\n        NotifyTwitter(\n            ckey=\"value\", csecret=\"value\", akey=\"value\", asecret=\"value\"\n        ),\n        NotifyTwitter,\n    )\n\n    assert isinstance(\n        NotifyTwitter(\n            ckey=\"value\",\n            csecret=\"value\",\n            akey=\"value\",\n            asecret=\"value\",\n            user=\"l2gnux\",\n        ),\n        NotifyTwitter,\n    )\n\n    # Invalid Target User\n    obj = NotifyTwitter(\n        ckey=\"value\",\n        csecret=\"value\",\n        akey=\"value\",\n        asecret=\"value\",\n        targets=\"%G@rB@g3\",\n    )\n\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n\ndef test_plugin_twitter_dm_caching(\n    mocker, twitter_url, good_message_response, good_media_response\n):\n    \"\"\"Verify that the `NotifyTwitter.{_user_cache,_whoami_cache}` caches work\n    as intended.\"\"\"\n\n    # This is the request to `account/verify_credentials.json`.\n    # Explicitly mock it here so the calls to it can be evaluated.\n    mock_get = mocker.patch(\"requests.get\")\n    mock_get.return_value = good_message_response\n\n    # This test case submits two notifications, so make sure to provide two\n    # mocked responses.\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.side_effect = [good_message_response, good_message_response]\n\n    # Make sure to start with empty caches.\n    if hasattr(NotifyTwitter, \"_user_cache\"):\n        NotifyTwitter._user_cache = {}\n    if hasattr(NotifyTwitter, \"_whoami_cache\"):\n        NotifyTwitter._whoami_cache = {}\n\n    # Create application objects.\n    obj = Apprise.instantiate(twitter_url)\n\n    # Send the first notification.\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Test call counts.\n    assert mock_get.call_count == 1\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://api.twitter.com/1.1/account/verify_credentials.json\"\n    )\n\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n    )\n\n    # Reset the mocks to start counting calls from scratch.\n    mock_get.reset_mock()\n    mock_post.reset_mock()\n\n    # Send another notification.\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    # Calls to `verify_credentials.json` will get cached by `NotifyTwitter`.\n    # So, the `GET` request to `verify_credentials.json` should only have been\n    # issued once.\n    assert mock_get.call_count == 0\n    assert mock_post.call_count == 1\n\n\ndef test_plugin_twitter_dm_attachments_basic(\n    mocker, twitter_url, good_message_response, good_media_response\n):\n    \"\"\"\n    NotifyTwitter() DM Attachment Checks - Basic\n    \"\"\"\n\n    mock_get = mocker.patch(\"requests.get\")\n    mock_post = mocker.patch(\"requests.post\")\n\n    # Epoch time:\n    epoch = datetime.fromtimestamp(0, timezone.utc)\n    mock_get.return_value = good_message_response\n    mock_post.return_value.headers = {\n        \"x-rate-limit-reset\": (\n            (datetime.now(timezone.utc) - epoch).total_seconds()\n        ),\n        \"x-rate-limit-remaining\": 1,\n    }\n\n    # The first response is for uploading the attachment,\n    # the second one for posting the actual message.\n    mock_post.side_effect = [good_media_response, good_message_response]\n\n    # Create application objects.\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Test call counts.\n    assert mock_get.call_count == 1\n    assert (\n        mock_get.call_args_list[0][0][0]\n        == \"https://api.twitter.com/1.1/account/verify_credentials.json\"\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n    )\n\n\ndef test_plugin_twitter_dm_attachments_message_fails(\n    mocker, twitter_url, good_media_response, bad_message_response\n):\n    \"\"\"Test case with a bad media response.\"\"\"\n\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.side_effect = [good_media_response, bad_message_response]\n\n    # Create application objects.\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification; it will fail because of the message response.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n    )\n\n\ndef test_plugin_twitter_dm_attachments_upload_fails(\n    mocker, twitter_url, good_message_response, bad_media_response\n):\n    \"\"\"Test case where upload fails.\"\"\"\n\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.side_effect = [bad_media_response, good_message_response]\n\n    # Create application objects.\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification; it will fail because of the media response.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Test call counts.\n    assert mock_post.call_count == 1\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n\n\ndef test_plugin_twitter_dm_attachments_invalid_attachment(\n    mocker, twitter_url, good_message_response\n):\n    \"\"\"Test case with an invalid attachment.\"\"\"\n\n    mock_post: Mock = mocker.patch(\"requests.post\")\n    mock_post.side_effect = [good_media_response, good_message_response]\n\n    # Create application objects.\n    # An invalid attachment will cause a failure.\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    )\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Verify no post requests have been issued, because attachment is not good.\n    assert mock_post.mock_calls == []\n\n\ndef test_plugin_twitter_dm_attachments_multiple(\n    mocker, twitter_url, good_message_response, good_media_response\n):\n\n    mock_post = mocker.patch(\"requests.post\")\n\n    mock_post.side_effect = [\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n    ]\n\n    # 4 images are produced\n    attach = [\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.jpeg\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.png\"),\n        # This one is not supported, so it's ignored gracefully\n        os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\"),\n    ]\n\n    # Create application objects.\n    obj = Apprise.instantiate(twitter_url)\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    assert mock_post.call_count == 8\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n    )\n    assert (\n        mock_post.call_args_list[5][0][0]\n        == \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n    )\n    assert (\n        mock_post.call_args_list[6][0][0]\n        == \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n    )\n    assert (\n        mock_post.call_args_list[7][0][0]\n        == \"https://api.twitter.com/1.1/direct_messages/events/new.json\"\n    )\n\n\ndef test_plugin_twitter_dm_attachments_multiple_oserror(\n    mocker, twitter_url, good_message_response, good_media_response\n):\n\n    # Inject an `OSError` into the middle of the operation.\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.side_effect = [good_media_response, OSError()]\n\n    # 2 images are produced\n    attach = [\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.png\"),\n        # This one is not supported, so it's ignored gracefully\n        os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\"),\n    ]\n\n    # Create application objects.\n    obj = Apprise.instantiate(twitter_url)\n\n    # We'll fail to send this time\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_basic(\n    mock_post, twitter_url, good_message_response, good_media_response\n):\n    \"\"\"\n    NotifyTwitter() Tweet Attachment Checks - Basic\n    \"\"\"\n\n    mock_post.side_effect = [good_media_response, good_message_response]\n\n    # Create application objects.\n    twitter_url += \"?mode=tweet\"\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Verify API calls.\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_more_logging(\n    mock_post, twitter_url, good_media_response\n):\n    \"\"\"\n    NotifyTwitter() Tweet Attachment Checks - More logging\n\n    TODO: The \"more logging\" aspect is not verified yet?\n    \"\"\"\n\n    good_tweet_response = good_response({\n        \"screen_name\": TWITTER_SCREEN_NAME,\n        \"id\": 9876,\n        # needed for additional logging\n        \"id_str\": \"12345\",\n        \"user\": {\n            \"screen_name\": TWITTER_SCREEN_NAME,\n        },\n    })\n\n    mock_post.side_effect = [good_media_response, good_tweet_response]\n\n    # Create application objects.\n    twitter_url += \"?mode=tweet\"\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification (again); this time there will be more logging\n    # TODO: The \"more logging\" aspect is not verified yet?\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    # Verify API calls.\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_bad_message_response(\n    mock_post, twitter_url, good_media_response, bad_message_response\n):\n\n    mock_post.side_effect = [good_media_response, bad_message_response]\n\n    # Create application objects.\n    twitter_url += \"?mode=tweet\"\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Our notification will fail now since our tweet will error out.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Verify API calls.\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_bad_message_response_unparseable(\n    mock_post, twitter_url, good_media_response\n):\n\n    bad_message_response = bad_response(\"\")\n    mock_post.side_effect = [good_media_response, bad_message_response]\n\n    # Create application objects.\n    twitter_url += \"?mode=tweet\"\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # The notification will fail now since the tweet will error out.\n    # This is the same test as above, except that the error response is not\n    # parseable.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Verify API calls.\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_upload_fails(\n    mock_post, twitter_url, good_media_response\n):\n\n    # Prepare a bad tweet response.\n    bad_tweet_response = bad_response({})\n\n    # Test case where upload fails.\n    mock_post.side_effect = [good_media_response, bad_tweet_response]\n\n    # Create application objects.\n    twitter_url += \"?mode=tweet\"\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"))\n\n    # Send our notification; it will fail because of the message response.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # Verify API calls.\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_invalid_attachment(\n    mock_post, twitter_url, good_message_response, good_media_response\n):\n\n    mock_post.side_effect = [good_media_response, good_message_response]\n\n    # Create application objects.\n    twitter_url += \"?mode=tweet\"\n    obj = Apprise.instantiate(twitter_url)\n    attach = AppriseAttachment(\n        os.path.join(TEST_VAR_DIR, \"/invalid/path/to/an/invalid/file.jpg\")\n    )\n\n    # An invalid attachment will cause a failure.\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    # No post request as attachment is not good.\n    assert mock_post.call_count == 0\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_multiple_batch(\n    mock_post, twitter_url, good_message_response, good_media_response\n):\n\n    mock_post.side_effect = [\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n    ]\n\n    # instantiate our object\n    obj = Apprise.instantiate(twitter_url + \"?mode=tweet\")\n\n    # 4 images are produced\n    attach = [\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.jpeg\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.png\"),\n        # This one is not supported, so it's ignored gracefully\n        os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\"),\n    ]\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    assert mock_post.call_count == 7\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n    assert (\n        mock_post.call_args_list[5][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n    # The 2 images are grouped together (batch mode)\n    assert (\n        mock_post.call_args_list[6][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_multiple_nobatch(\n    mock_post, twitter_url, good_message_response, good_media_response\n):\n\n    mock_post.side_effect = [\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_media_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n        good_message_response,\n    ]\n\n    # instantiate our object (without a batch mode)\n    obj = Apprise.instantiate(twitter_url + \"?mode=tweet&batch=no\")\n\n    # 4 images are produced\n    attach = [\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.jpeg\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.png\"),\n        # This one is not supported, so it's ignored gracefully\n        os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\"),\n    ]\n\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is True\n    )\n\n    assert mock_post.call_count == 8\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[2][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[3][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[4][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n    assert (\n        mock_post.call_args_list[5][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n    assert (\n        mock_post.call_args_list[6][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n    assert (\n        mock_post.call_args_list[7][0][0]\n        == \"https://api.twitter.com/1.1/statuses/update.json\"\n    )\n\n\n@patch(\"requests.post\")\ndef test_plugin_twitter_tweet_attachments_multiple_oserror(\n    mock_post, twitter_url, good_media_response\n):\n\n    # We have an OSError thrown in the middle of our preparation\n    mock_post.side_effect = [good_media_response, OSError()]\n\n    # 2 images are produced\n    attach = [\n        os.path.join(TEST_VAR_DIR, \"apprise-test.gif\"),\n        os.path.join(TEST_VAR_DIR, \"apprise-test.png\"),\n        # This one is not supported, so it's ignored gracefully\n        os.path.join(TEST_VAR_DIR, \"apprise-test.mp4\"),\n    ]\n\n    # We'll fail to send this time\n    obj = Apprise.instantiate(twitter_url + \"?mode=tweet\")\n    assert (\n        obj.notify(\n            body=\"body\",\n            title=\"title\",\n            notify_type=NotifyType.INFO,\n            attach=attach,\n        )\n        is False\n    )\n\n    assert mock_post.call_count == 2\n    assert (\n        mock_post.call_args_list[0][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n    assert (\n        mock_post.call_args_list[1][0][0]\n        == \"https://upload.twitter.com/1.1/media/upload.json\"\n    )\n"
  },
  {
    "path": "tests/test_plugin_vapid.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nimport sys\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import asset, exception, url\nfrom apprise.common import PersistentStoreMode\nfrom apprise.plugins.vapid import NotifyVapid\nfrom apprise.plugins.vapid.subscription import (\n    WebPushSubscription,\n    WebPushSubscriptionManager,\n)\nfrom apprise.utils.pem import ApprisePEMController\n\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# a test UUID we can use\nSUBSCRIBER = \"user@example.com\"\n\nPLUGIN_ID = \"vapid\"\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"vapid://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vapid://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vapid://invalid-subscriber\",\n        {\n            # An invalid Subscriber\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vapid://user@example.com\",\n        {\n            # bare bone requirements met, but we don't have our subscription\n            # file or our private key (pem)\n            \"instance\": NotifyVapid,\n            # We'll fail to respond because we would not have found any\n            # configuration to load\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"vapid://user@example.com?keyfile=invalid&subfile=invalid\",\n        {\n            # Test passing keyfile and subfile on our path (even if invalid)\n            \"instance\": NotifyVapid,\n            # We'll fail to respond because we would not have found any\n            # configuration to load\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"vapid://user@example.com/newuser@example.com\",\n        {\n            # we don't have our subscription file or private key\n            \"instance\": NotifyVapid,\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"vapid://user@example.ca/newuser@example.ca\",\n        {\n            \"instance\": NotifyVapid,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"vapid://user@example.uk/newuser@example.uk\",\n        {\n            \"instance\": NotifyVapid,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"vapid://user@example.au/newuser@example.au\",\n        {\n            \"instance\": NotifyVapid,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\n@pytest.fixture\ndef patch_persistent_store_namespace(tmpdir):\n    \"\"\"Force an easy to test environment.\"\"\"\n    with (\n        mock.patch.object(url.URLBase, \"url_id\", return_value=PLUGIN_ID),\n        mock.patch.object(\n            asset.AppriseAsset, \"storage_mode\", PersistentStoreMode.AUTO\n        ),\n        mock.patch.object(asset.AppriseAsset, \"storage_path\", str(tmpdir)),\n    ):\n\n        tmp_dir = tmpdir.mkdir(PLUGIN_ID)\n        # Return the directory name\n        yield str(tmp_dir)\n\n\n@pytest.fixture\ndef subscription_reference():\n    return {\n        \"user@example.com\": {\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/default\",\n            \"keys\": {\n                \"p256dh\": (\n                    \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                    \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n                ),\n                \"auth\": \"k9Xzm43nBGo=\",\n            },\n        },\n        \"user1\": {\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n            \"keys\": {\n                \"p256dh\": (\n                    \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                    \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n                ),\n                \"auth\": \"k9Xzm43nBGo=\",\n            },\n        },\n        \"user2\": {\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/def456\",\n            \"keys\": {\n                \"p256dh\": (\n                    \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                    \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n                ),\n                \"auth\": \"k9Xzm43nBGo=\",\n            },\n        },\n    }\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_vapid_urls():\n    \"\"\"\n    NotifyVapid() Apprise URLs - No Config\n\n    \"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_vapid_urls_with_required_assets(\n    patch_persistent_store_namespace, subscription_reference\n):\n    \"\"\"NotifyVapid() Apprise URLs With Config.\"\"\"\n\n    # Determine our store\n    pc = ApprisePEMController(path=patch_persistent_store_namespace)\n    assert pc.keygen() is True\n\n    # Write our subscriptions file to disk\n    subscription_file = os.path.join(\n        patch_persistent_store_namespace, NotifyVapid.vapid_subscription_file\n    )\n\n    with open(subscription_file, \"w\") as f:\n        f.write(json.dumps(subscription_reference))\n\n    tests = (\n        (\n            \"vapid://user@example.com\",\n            {\n                # user@example.com loaded (also used as subscriber id)\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user@example.com/newuser@example.com\",\n            {\n                # no newuser@example.com key entry\n                \"instance\": NotifyVapid,\n                \"notify_response\": False,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2\",\n            {\n                # We'll succesfully notify 2 users\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user1?to=user2&from=user@example.com\",\n            {\n                # We'll succesfully notify 2 users\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://?to=user2&from=user@example.com\",\n            {\n                # No host provided\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user@example.com?to=user2&from=user@example.com\",\n            {\n                # We'll succesfully notify 2 users\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2&ttl=15\",\n            {\n                # test ttl\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2&ttl=\",\n            {\n                # test ttl\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2&ttl=invalid\",\n            {\n                # test ttl\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2&ttl=-4000\",\n            {\n                # bad ttl\n                \"instance\": TypeError,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2&mode=edge\",\n            {\n                # test mode\n                \"instance\": NotifyVapid,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2&mode=\",\n            {\n                # test mode\n                \"instance\": TypeError,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1?to=user2&mode=invalid\",\n            {\n                # test mode more\n                \"instance\": TypeError,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1\",\n            {\n                \"instance\": NotifyVapid,\n                # force a failure\n                \"response\": False,\n                \"requests_response_code\": requests.codes.internal_server_error,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1\",\n            {\n                \"instance\": NotifyVapid,\n                # throw a bizarre code forcing us to fail to look it up\n                \"response\": False,\n                \"requests_response_code\": 999,\n            },\n        ),\n        (\n            \"vapid://user@example.com/user1\",\n            {\n                \"instance\": NotifyVapid,\n                # Throws a series of connection and transfer exceptions\n                # when this flag is set and tests that we gracefully handle\n                # them\n                \"test_requests_exceptions\": True,\n            },\n        ),\n    )\n\n    AppriseURLTester(tests=tests).run_all()\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_vapid_subscriptions(tmpdir):\n    \"\"\"NotifyVapid() Subscriptions.\"\"\"\n\n    # Temporary directory\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n\n    with pytest.raises(exception.AppriseInvalidData):\n        # Integer not supported\n        WebPushSubscription(42)\n\n    with pytest.raises(exception.AppriseInvalidData):\n        # Not the correct format\n        WebPushSubscription(\"bad-content\")\n\n    with pytest.raises(exception.AppriseInvalidData):\n        # Invalid JSON\n        WebPushSubscription(\"{\")\n\n    with pytest.raises(exception.AppriseInvalidData):\n        # Empty Dictionary\n        WebPushSubscription({})\n\n    with pytest.raises(exception.AppriseInvalidData):\n        WebPushSubscription({\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n            \"keys\": {\n                \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=\",\n                \"auth\": 42,\n            },\n        })\n\n    with pytest.raises(exception.AppriseInvalidData):\n        WebPushSubscription({\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n            \"keys\": {\n                \"p256dh\": 42,\n                \"auth\": \"k9Xzm43nBGo=\",\n            },\n        })\n\n    with pytest.raises(exception.AppriseInvalidData):\n        WebPushSubscription({\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n        })\n\n    with pytest.raises(exception.AppriseInvalidData):\n        WebPushSubscription({\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n            \"keys\": {},\n        })\n\n    with pytest.raises(exception.AppriseInvalidData):\n        # Invalid p256dh public key provided\n        wps = WebPushSubscription({\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n            \"keys\": {\n                \"p256dh\": \"BNcW4oA7zq5H9TKIrA3XfKclN2fX9P_7NR=\",\n                \"auth\": \"k9Xzm43nBGo=\",\n            },\n        })\n\n    # An empty object\n    wps = WebPushSubscription()\n    assert bool(wps) is False\n    assert isinstance(wps.json(), str)\n    assert json.loads(wps.json())\n    assert str(wps) == \"\"\n    assert wps.auth is None\n    assert wps.endpoint is None\n    assert wps.p256dh is None\n    assert wps.public_key is None\n    # We can't write anything as there is nothing loaded\n    assert wps.write(os.path.join(str(tmpdir0), \"subscriptions.json\")) is False\n\n    # A valid key\n    wps = WebPushSubscription({\n        \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n        \"keys\": {\n            \"p256dh\": (\n                \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n            ),\n            \"auth\": \"k9Xzm43nBGo=\",\n        },\n    })\n\n    assert bool(wps) is True\n    assert isinstance(wps.json(), str)\n    assert json.loads(wps.json())\n    assert str(wps) == \"abc123\"\n    assert wps.auth == \"k9Xzm43nBGo=\"\n    assert wps.endpoint == \"https://fcm.googleapis.com/fcm/send/abc123\"\n    assert (\n        wps.p256dh\n        == \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n        \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n    )\n    assert wps.public_key is not None\n\n    # Currently no files here\n    assert os.listdir(str(tmpdir0)) == []\n\n    # Bad content\n    assert wps.write(object) is False\n    assert wps.write(None) is False\n    # Can't write to a name already taken by as a directory\n    assert wps.write(str(tmpdir0)) is False\n    # Can't write to a name already taken by as a directory\n    assert wps.write(os.path.join(str(tmpdir0), \"subscriptions.json\")) is True\n    assert os.listdir(str(tmpdir0)) == [\"subscriptions.json\"]\n\n\n@pytest.mark.skipif(\n    \"cryptography\" in sys.modules,\n    reason=\"Requires that cryptography NOT be installed\",\n)\ndef test_plugin_vapid_subscriptions_without_c():\n    \"\"\"NotifyVapid() Subscriptions (no Cryptography)\"\"\"\n    with pytest.raises(exception.AppriseInvalidData):\n        # A valid key that can't be loaded because crytography is missing\n        WebPushSubscription({\n            \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n            \"keys\": {\n                \"p256dh\": (\n                    \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                    \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n                ),\n                \"auth\": \"k9Xzm43nBGo=\",\n            },\n        })\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_plugin_vapid_subscription_manager(tmpdir):\n    \"\"\"NotifyVapid() Subscription Manager.\"\"\"\n\n    # Temporary directory\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n\n    with pytest.raises(exception.AppriseInvalidData):\n        # An invalid object\n        smgr = WebPushSubscriptionManager()\n        smgr[\"abc\"] = \"invalid\"\n\n    with pytest.raises(exception.AppriseInvalidData):\n        # An invalid object\n        smgr = WebPushSubscriptionManager()\n        smgr += \"invalid\"\n\n    smgr = WebPushSubscriptionManager()\n\n    assert bool(smgr) is False\n    assert len(smgr) == 0\n\n    sub = {\n        \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n        \"keys\": {\n            \"p256dh\": (\n                \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n            ),\n            \"auth\": \"k9Xzm43nBGo=\",\n        },\n    }\n\n    assert smgr.add(sub) is True\n    assert bool(smgr) is True\n    assert len(smgr) == 1\n\n    # Same sub (overwrites same slot)\n    smgr += sub\n    assert bool(smgr) is True\n    assert len(smgr) == 1\n\n    # This makes a copy\n    smgr[\"abc\"] = smgr[\"abc123\"]\n    assert bool(smgr) is True\n    assert len(smgr) == 2\n\n    assert isinstance(smgr[\"abc123\"], WebPushSubscription)\n\n    # Currently no files here\n    assert os.listdir(str(tmpdir0)) == []\n\n    # Write our content\n    assert smgr.write(os.path.join(str(tmpdir0), \"subscriptions.json\")) is True\n\n    assert os.listdir(str(tmpdir0)) == [\"subscriptions.json\"]\n\n    # Reset our object\n    smgr.clear()\n    assert bool(smgr) is False\n    assert len(smgr) == 0\n\n    # Load our content back\n    assert smgr.load(os.path.join(str(tmpdir0), \"subscriptions.json\")) is True\n    assert bool(smgr) is True\n    assert len(smgr) == 2\n\n    # Write over our file using the standard Subscription format\n    assert (\n        smgr[\"abc123\"].write(os.path.join(str(tmpdir0), \"subscriptions.json\"))\n        is True\n    )\n\n    # We can still open this type as well\n    assert smgr.load(os.path.join(str(tmpdir0), \"subscriptions.json\")) is True\n    assert bool(smgr) is True\n    assert len(smgr) == 1\n\n    smgr.clear()\n    bad_entry = {\n        \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n        \"keys\": {\n            \"p256dh\": \"invalid\",\n            \"auth\": \"garbage\",\n        },\n    }\n\n    subscriptions = os.path.join(str(tmpdir0), \"subscriptions.json\")\n    with open(subscriptions, \"w\", encoding=\"utf-8\") as f:\n        # A bad JSON file\n        f.write(\"{\")\n    assert smgr.load(subscriptions) is False\n\n    with open(subscriptions, \"w\", encoding=\"utf-8\") as f:\n        # not expected dictionary\n        f.write(\"null\")\n    assert smgr.load(subscriptions) is False\n\n    subscriptions = os.path.join(str(tmpdir0), \"subscriptions.json\")\n    with open(subscriptions, \"w\", encoding=\"utf-8\") as f:\n        json.dump(bad_entry, f)\n    assert smgr.load(subscriptions) is False\n\n    # Create bad data\n    bad_data = {\n        \"bad1\": bad_entry,\n        \"bad2\": bad_entry,\n        \"bad3\": bad_entry,\n        \"bad4\": bad_entry,\n    }\n    subscriptions = os.path.join(str(tmpdir0), \"subscriptions.json\")\n    with open(subscriptions, \"w\", encoding=\"utf-8\") as f:\n        json.dump(bad_data, f)\n    assert smgr.load(subscriptions) is False\n    assert smgr.load(\"invalid-file\") is False\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\n@mock.patch(\"requests.post\")\ndef test_plugin_vapid_initializations(mock_post, tmpdir):\n    \"\"\"NotifyVapid() Initializations.\"\"\"\n\n    # Assign our mock object our return value\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    okay_response.content = \"\"\n    mock_post.return_value = okay_response\n\n    # Temporary directory\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n\n    # Write our subfile\n    smgr = WebPushSubscriptionManager()\n    sub = {\n        \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n        \"keys\": {\n            \"p256dh\": (\n                \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n            ),\n            \"auth\": \"k9Xzm43nBGo=\",\n        },\n    }\n    subfile = os.path.join(str(tmpdir0), \"subscriptions.json\")\n    assert smgr.add(sub) is True\n    assert smgr.add(smgr[\"abc123\"]) is True\n    assert os.listdir(str(tmpdir0)) == []\n\n    with mock.patch(\"json.dump\", side_effect=OSError):\n        # We will fial to write\n        assert smgr.write(subfile) is False\n\n    assert smgr.write(subfile) is True\n    assert os.listdir(str(tmpdir0)) == [\"subscriptions.json\"]\n    assert isinstance(smgr.json(), str)\n\n    asset_ = asset.AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir0),\n        # Auto-gen our private/public key pair\n        pem_autogen=True,\n    )\n\n    # Auto-Key Generation\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        subfile=subfile,\n        asset=asset_,\n    )\n    assert isinstance(obj, NotifyVapid)\n    # Our subscription directory + our\n    # persistent store where our keys were generated\n    assert len(os.listdir(str(tmpdir0))) == 2\n\n    # Second call re-references keys previously generated\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        subfile=subfile,\n        asset=asset_,\n    )\n    assert isinstance(obj, NotifyVapid)\n    assert isinstance(obj.url(), str)\n    assert obj.send(\"test\") is True\n    # A second message makes no difference; what is loaded into memory is used\n    assert obj.send(\"test\") is True\n\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        subfile=\"/a/bad/path\",\n        asset=asset_,\n    )\n    assert isinstance(obj, NotifyVapid)\n    assert isinstance(obj.url(), str)\n    assert obj.send(\"test\") is False\n    # A second message makes no difference; what is loaded into memory is used\n    assert obj.send(\"test\") is False\n\n    # Detect our keyfile\n    cache_dir = next(\n        x\n        for x in os.listdir(str(tmpdir0))\n        if not x.endswith(\"subscriptions.json\")\n    )\n\n    # Test fixed assignment to our keyfile\n    keyfile = os.path.join(str(tmpdir0), cache_dir, \"private_key.pem\")\n    assert os.path.exists(keyfile)\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        keyfile=keyfile,\n        subfile=subfile,\n        asset=asset_,\n    )\n    assert isinstance(obj, NotifyVapid)\n    assert isinstance(obj.url(), str)\n    assert obj.send(\"test\") is True\n    # A second message makes no difference; what is loaded into memory is used\n    assert obj.send(\"test\") is True\n\n    # Invalid Keyfile\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        keyfile=subfile,\n        subfile=subfile,\n        asset=asset_,\n    )\n    assert isinstance(obj, NotifyVapid)\n    assert isinstance(obj.url(), str)\n    assert obj.send(\"test\") is False\n    # A second message makes no difference; what is loaded into memory is used\n    assert obj.send(\"test\") is False\n\n    # AutoGen Temporary directory\n    tmpdir1 = tmpdir.mkdir(\"tmp01\")\n    asset2 = asset.AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir1),\n        # Auto-gen our private/public key pair\n        pem_autogen=True,\n    )\n\n    assert os.listdir(str(tmpdir1)) == []\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        keyfile=keyfile,\n        asset=asset2,\n    )\n    assert isinstance(obj, NotifyVapid)\n    assert isinstance(obj.url(), str)\n    # We have a temporary subscription file we can use\n    assert os.listdir(str(tmpdir1)) == [\"00088ad3\"]\n    # We will have a dud configuration file, but at least it's something\n    # to help the user with\n    assert obj.send(\"test\") is False\n    # Second instance fails as well\n    assert obj.send(\"test\") is False\n\n    # AutoGen Temporary directory\n    tmpdir2 = tmpdir.mkdir(\"tmp02\")\n    asset3 = asset.AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir2),\n        # Auto-gen our private/public key pair\n        pem_autogen=True,\n    )\n\n    # Test invalid keyfile\n    assert os.path.exists(keyfile)\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        keyfile=\"invalid-file\",\n        subfile=subfile,\n        asset=asset3,\n    )\n    assert isinstance(obj, NotifyVapid)\n    assert isinstance(obj.url(), str)\n    assert obj.send(\"test\") is False\n    # A second message makes no difference; what is loaded into memory is used\n    assert obj.send(\"test\") is False\n\n\n@pytest.mark.skipif(\n    \"cryptography\" in sys.modules,\n    reason=\"Requires that cryptography NOT be installed\",\n)\ndef test_plugin_vapid_initializations_without_c(tmpdir):\n    \"\"\"NotifyVapid() Initializations without cryptography.\"\"\"\n    # Temporary directory\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n\n    # Write our subfile\n    smgr = WebPushSubscriptionManager()\n    sub = {\n        \"endpoint\": \"https://fcm.googleapis.com/fcm/send/abc123\",\n        \"keys\": {\n            \"p256dh\": (\n                \"BI2RNIK2PkeCVoEfgVQNjievBi4gWvZxMiuCpOx6K6qCO\"\n                \"5caru5QCPuc-nEaLplbbFkHxTrR9YzE8ZkTjie5Fq0\"\n            ),\n            \"auth\": \"k9Xzm43nBGo=\",\n        },\n    }\n    subfile = os.path.join(str(tmpdir0), \"subscriptions.json\")\n    assert smgr.add(sub) is False\n    asset_ = asset.AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir0),\n        # Auto-gen our private/public key pair\n        pem_autogen=True,\n    )\n\n    # Auto-Key Generation\n    obj = NotifyVapid(\n        \"user@example.ca\",\n        targets=[\n            \"abc123\",\n        ],\n        subfile=subfile,\n        asset=asset_,\n    )\n    assert isinstance(obj, NotifyVapid)\n"
  },
  {
    "path": "tests/test_plugin_viber.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.viber import NotifyViber\n\nlogging.disable(logging.CRITICAL)\n\nVIBER_GOOD_RESPONSE = dumps({\"status\": 0, \"status_message\": \"ok\"})\nVIBER_BAD_RESPONSE = dumps(\n    {\"status\": 12, \"status_message\": \"Too many requests\"})\n\n# Our Testing URLs\napprise_url_tests = (\n    (\"viber://\", False),\n    (\"viber:///\", False),\n    (\"viber://tokena\", {\n        \"instance\": NotifyViber,\n        \"notify_response\": False}),\n    (\"viber://?token=tokenb\", {\n        \"instance\": NotifyViber,\n        \"notify_response\": False}),\n    (\"viber://token/targetx\", {\n        \"instance\": NotifyViber,\n        # Our response expected server response\n        \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://token/t1/t2?from=Viber%20Bot\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://t1/t2?token=token\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://?token=token&to=t5\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://token/t3?avatar=value\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://token/?to=abc,def\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://token/m12/?from={}\".format(\n        \"a\" * (NotifyViber.viber_sender_name_limit + 1)), {\n            \"instance\": NotifyViber,\n            \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://token/m12/?from={}\".format(\n        \"b\" * (NotifyViber.viber_sender_name_limit)), {\n            \"instance\": NotifyViber,\n            \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://?token=token&to=hij,klm\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_GOOD_RESPONSE}),\n    (\"viber://?token=token&to=nop,qrs\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_BAD_RESPONSE,\n        \"notify_response\": False}),\n    (\"viber://?token=token&to=tuv,wxy\", {\n        \"instance\": NotifyViber,\n        # Bad JSON\n        \"requests_response_text\": \"{\",\n        \"notify_response\": False}),\n\n    # Privacy redaction of token\n    (\"viber://token/t10\", {\n        \"instance\": NotifyViber,\n        \"requests_response_text\": VIBER_GOOD_RESPONSE,\n        \"privacy_url\": \"viber://****/t10\"}),\n    (\n        \"viber://token/targetZ\",\n        {\n            \"instance\": NotifyViber,\n            # throw a bizarre code forcing us to fail to look it up\n            \"requests_response_text\": VIBER_GOOD_RESPONSE,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"viber://token/targetZ\",\n        {\n            \"instance\": NotifyViber,\n            # force a failure\n            \"response\": False,\n            \"requests_response_text\": VIBER_BAD_RESPONSE,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"viber://token/targetY\",\n        {\n            \"instance\": NotifyViber,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"requests_response_text\": VIBER_GOOD_RESPONSE,\n            \"test_requests_exceptions\": True,\n        },\n    )\n)\n\n\ndef test_plugin_viber_urls():\n    \"\"\"Verify URL parsing, privacy, and basic validation.\"\"\"\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_viber_http_error_and_exception(mocker):\n    \"\"\"Verify HTTP error and requests exception paths.\"\"\"\n    post = mocker.patch(\"requests.post\")\n\n    post.return_value.status_code = 400\n    a = Apprise()\n    assert a.add(\"viber://token/target2\") is True\n    assert a.notify(\"test\") is False\n\n    post.side_effect = requests.RequestException(\"boom\")\n    a = Apprise()\n    assert a.add(\"viber://token/target2\") is True\n    assert a.notify(\"test\") is False\n"
  },
  {
    "path": "tests/test_plugin_voipms.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.voipms import NotifyVoipms\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"voipms://\",\n        {\n            # No email/password specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"voipms://@:\",\n        {\n            # Invalid url\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"voipms://{}/{}\".format(\"user@example.com\", \"1\" * 11),\n        {\n            # No password specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"voipms://:{}\".format(\"password\"),\n        {\n            # No email specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"voipms://{}:{}/{}\".format(\"user@\", \"pass\", \"1\" * 11),\n        {\n            # Check valid email\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"voipms://{password}:{email}\".format(\n            email=\"user@example.com\", password=\"password\"\n        ),\n        {\n            # No from_phone specified\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid phone number test\n    (\n        \"voipms://{password}:{email}/1613\".format(\n            email=\"user@example.com\", password=\"password\"\n        ),\n        {\n            # Invalid phone number\n            \"instance\": TypeError,\n        },\n    ),\n    # Invalid country code phone number test\n    (\n        \"voipms://{password}:{email}/01133122446688\".format(\n            email=\"user@example.com\", password=\"password\"\n        ),\n        {\n            # Non North American phone number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"voipms://{password}:{email}/{from_phone}/{targets}/\".format(\n            email=\"user@example.com\",\n            password=\"password\",\n            from_phone=\"16134448888\",\n            targets=\"/\".join([\"26134442222\"]),\n        ),\n        {\n            # Invalid target phone number\n            \"instance\": NotifyVoipms,\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"voipms://{password}:{email}/{from_phone}\".format(\n            email=\"user@example.com\",\n            password=\"password\",\n            from_phone=\"16138884444\",\n        ),\n        {\n            \"instance\": NotifyVoipms,\n            # No targets specified\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"voipms://{password}:{email}/?from={from_phone}\".format(\n            email=\"user@example.com\",\n            password=\"password\",\n            from_phone=\"16138884444\",\n        ),\n        {\n            \"instance\": NotifyVoipms,\n            # No targets specified\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"voipms://{password}:{email}/{from_phone}/{targets}/\".format(\n            email=\"user@example.com\",\n            password=\"password\",\n            from_phone=\"16138884444\",\n            targets=\"/\".join([\"16134442222\"]),\n        ),\n        {\n            # Valid\n            \"instance\": NotifyVoipms,\n            \"response\": True,\n            \"privacy_url\": \"voipms://p...d:user@example.com/16...4\",\n        },\n    ),\n    (\n        \"voipms://{password}:{email}/{from_phone}/{targets}/\".format(\n            email=\"user@example.com\",\n            password=\"password\",\n            from_phone=\"16138884444\",\n            targets=\"/\".join([\"16134442222\", \"16134443333\"]),\n        ),\n        {\n            # Valid multiple targets\n            \"instance\": NotifyVoipms,\n            \"response\": True,\n            \"privacy_url\": \"voipms://p...d:user@example.com/16...4\",\n        },\n    ),\n    (\n        \"voipms://{password}:{email}/?from={from_phone}&to={targets}\".format(\n            email=\"user@example.com\",\n            password=\"password\",\n            from_phone=\"16138884444\",\n            targets=\"16134448888\",\n        ),\n        {\n            # Valid\n            \"instance\": NotifyVoipms,\n        },\n    ),\n    (\n        \"voipms://{password}:{email}/{from_phone}/{targets}/\".format(\n            email=\"user@example.com\",\n            password=\"password\",\n            from_phone=\"16138884444\",\n            targets=\"16134442222\",\n        ),\n        {\n            \"instance\": NotifyVoipms,\n            # Throws a series of errors\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_voipms():\n    \"\"\"NotifyVoipms() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.get\")\ndef test_plugin_voipms_edge_cases(mock_get):\n    \"\"\"NotifyVoipms() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_get.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    email = \"user@example.com\"\n    password = \"password\"\n    source = \"+1 (555) 123-3456\"\n    targets = \"+1 (555) 123-9876\"\n\n    # No email specified\n    with pytest.raises(TypeError):\n        NotifyVoipms(email=None, source=source)\n\n    # a error response is returned\n    response.status_code = 400\n    response.content = dumps({\n        \"code\": 21211,\n        \"message\": \"Unable to process your request.\",\n    })\n    mock_get.return_value = response\n\n    # Initialize our object\n    obj = Apprise.instantiate(\n        f\"voipms://{password}:{email}/{source}/{targets}\"\n    )\n\n    assert isinstance(obj, NotifyVoipms)\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n\n\n@mock.patch(\"requests.get\")\ndef test_plugin_voipms_non_success_status(mock_get):\n    \"\"\"NotifyVoipms() Non Success Status.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_get.return_value = response\n\n    # A 200 response is returned but non-success message\n    response.status_code = 200\n    response.content = dumps({\n        \"status\": \"invalid_credentials\",\n        \"message\": \"Username or Password is incorrect\",\n    })\n\n    obj = Apprise.instantiate(\n        \"voipms://{password}:{email}/{source}/{targets}\".format(\n            email=\"user@example.com\",\n            password=\"badpassword\",\n            source=\"16134448888\",\n            targets=\"16134442222\",\n        )\n    )\n\n    assert isinstance(obj, NotifyVoipms)\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n\n    response.content = \"{\"\n    assert obj.send(\"title\", \"body\") is False\n"
  },
  {
    "path": "tests/test_plugin_vonage.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.vonage import NotifyVonage\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"vonage://\",\n        {\n            # No API Key specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vonage://:@/\",\n        {\n            # invalid Auth key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vonage://AC{}@12345678\".format(\"a\" * 8),\n        {\n            # Just a key provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vonage://AC{}:{}@{}\".format(\"a\" * 8, \"b\" * 16, \"3\" * 9),\n        {\n            # key and secret provided and from but invalid from no\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vonage://AC{}:{}@{}/?ttl=0\".format(\"b\" * 8, \"c\" * 16, \"3\" * 11),\n        {\n            # Invalid ttl defined\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vonage://AC{}:{}@{}\".format(\"d\" * 8, \"e\" * 16, \"a\" * 11),\n        {\n            # Invalid source number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"vonage://AC{}:{}@{}/123/{}/abcd/\".format(\n            \"f\" * 8, \"g\" * 16, \"3\" * 11, \"9\" * 15\n        ),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifyVonage,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"vonage://A...f:****@\",\n        },\n    ),\n    (\n        \"vonage://AC{}:{}@{}\".format(\"h\" * 8, \"i\" * 16, \"5\" * 11),\n        {\n            # using phone no with no target - we text ourselves in\n            # this case\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"vonage://_?key=AC{}&secret={}&from={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"vonage://_?key=AC{}&secret={}&source={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing (use source instead\n            # of from)\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"vonage://_?key=AC{}&secret={}&from={}&to={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"7\" * 13\n        ),\n        {\n            # use to=\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"vonage://AC{}:{}@{}\".format(\"a\" * 8, \"b\" * 16, \"6\" * 11),\n        {\n            \"instance\": NotifyVonage,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"vonage://AC{}:{}@{}\".format(\"a\" * 8, \"b\" * 16, \"6\" * 11),\n        {\n            \"instance\": NotifyVonage,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    # Nexmo Backwards Support\n    (\n        \"nexmo://\",\n        {\n            # No API Key specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nexmo://:@/\",\n        {\n            # invalid Auth key\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nexmo://AC{}@12345678\".format(\"a\" * 8),\n        {\n            # Just a key provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nexmo://AC{}:{}@{}\".format(\"a\" * 8, \"b\" * 16, \"3\" * 9),\n        {\n            # key and secret provided and from but invalid from no\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nexmo://AC{}:{}@{}/?ttl=0\".format(\"b\" * 8, \"c\" * 16, \"3\" * 11),\n        {\n            # Invalid ttl defined\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nexmo://AC{}:{}@{}\".format(\"d\" * 8, \"e\" * 16, \"a\" * 11),\n        {\n            # Invalid source number\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"nexmo://AC{}:{}@{}/123/{}/abcd/\".format(\n            \"f\" * 8, \"g\" * 16, \"3\" * 11, \"9\" * 15\n        ),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifyVonage,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"vonage://A...f:****@\",\n        },\n    ),\n    (\n        \"nexmo://AC{}:{}@{}\".format(\"h\" * 8, \"i\" * 16, \"5\" * 11),\n        {\n            # using phone no with no target - we text ourselves in\n            # this case\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"nexmo://_?key=AC{}&secret={}&from={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"nexmo://_?key=AC{}&secret={}&source={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing (use source instead of\n            # from)\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"nexmo://_?key=AC{}&secret={}&from={}&to={}\".format(\n            \"a\" * 8, \"b\" * 16, \"5\" * 11, \"7\" * 13\n        ),\n        {\n            # use to=\n            \"instance\": NotifyVonage,\n        },\n    ),\n    (\n        \"nexmo://AC{}:{}@{}\".format(\"a\" * 8, \"b\" * 16, \"6\" * 11),\n        {\n            \"instance\": NotifyVonage,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"nexmo://AC{}:{}@{}\".format(\"a\" * 8, \"b\" * 16, \"6\" * 11),\n        {\n            \"instance\": NotifyVonage,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_vonage_urls():\n    \"\"\"NotifyVonage() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_vonage_edge_cases(mock_post):\n    \"\"\"NotifyVonage() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    apikey = \"AC{}\".format(\"b\" * 8)\n    secret = \"{}\".format(\"b\" * 16)\n    source = \"+1 (555) 123-3456\"\n\n    # No apikey specified\n    with pytest.raises(TypeError):\n        NotifyVonage(apikey=None, secret=secret, source=source)\n\n    with pytest.raises(TypeError):\n        NotifyVonage(apikey=\"  \", secret=secret, source=source)\n\n    # No secret specified\n    with pytest.raises(TypeError):\n        NotifyVonage(apikey=apikey, secret=None, source=source)\n\n    with pytest.raises(TypeError):\n        NotifyVonage(apikey=apikey, secret=\"  \", source=source)\n\n    # a error response\n    response.status_code = 400\n    response.content = dumps({\n        \"code\": 21211,\n        \"message\": \"The 'To' number +1234567 is not a valid phone number.\",\n    })\n    mock_post.return_value = response\n\n    # Initialize our object\n    obj = NotifyVonage(apikey=apikey, secret=secret, source=source)\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n"
  },
  {
    "path": "tests/test_plugin_webex_teams.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.webexteams import NotifyWebexTeams\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"wxteams://\",\n        {\n            # Teams Token missing\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"wxteams://:@/\",\n        {\n            # We don't have strict host checking on for wxteams, so this URL\n            # actually becomes parseable and :@ becomes a hostname.\n            # The below errors because a second token wasn't found\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"wxteams://{}\".format(\"a\" * 80),\n        {\n            # token provided - we're good\n            \"instance\": NotifyWebexTeams,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxteams://a...a/\",\n        },\n    ),\n    (\n        \"wxteams://?token={}\".format(\"a\" * 80),\n        {\n            # token provided - we're good\n            \"instance\": NotifyWebexTeams,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxteams://a...a/\",\n        },\n    ),\n    (\n        \"webex://{}\".format(\"a\" * 140),\n        {\n            # token provided - we're good\n            \"instance\": NotifyWebexTeams,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxteams://a...a/\",\n        },\n    ),\n    # Support Native URLs\n    (\n        \"https://api.ciscospark.com/v1/webhooks/incoming/{}\".format(\"a\" * 80),\n        {\n            # token provided - we're good\n            \"instance\": NotifyWebexTeams,\n        },\n    ),\n    # Support New Native URLs\n    (\n        \"https://webexapis.com/v1/webhooks/incoming/{}\".format(\"a\" * 100),\n        {\n            # token provided - we're good\n            \"instance\": NotifyWebexTeams,\n        },\n    ),\n    # Support Native URLs with arguments\n    (\n        \"https://api.ciscospark.com/v1/webhooks/incoming/{}?format=text\"\n        .format(\"a\" * 80),\n        {\n            # token provided - we're good\n            \"instance\": NotifyWebexTeams,\n        },\n    ),\n    (\n        \"wxteams://{}\".format(\"a\" * 80),\n        {\n            \"instance\": NotifyWebexTeams,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"wxteams://{}\".format(\"a\" * 80),\n        {\n            \"instance\": NotifyWebexTeams,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"wxteams://{}\".format(\"a\" * 80),\n        {\n            \"instance\": NotifyWebexTeams,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_webex_teams_urls():\n    \"\"\"NotifyWebexTeams() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_wecombot.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise.plugins.wecombot import NotifyWeComBot\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"wecombot://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"wecombot://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"wecombot://botkey\",\n        {\n            # Minimum requirements met\n            \"instance\": NotifyWeComBot,\n        },\n    ),\n    (\n        \"wecombot://?key=botkey\",\n        {\n            # Test ?key=\n            \"instance\": NotifyWeComBot,\n        },\n    ),\n    # Support Native URLs\n    (\n        \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=BOTKEY\",\n        {\n            \"instance\": NotifyWeComBot,\n        },\n    ),\n    (\n        \"https://qyapi.weixin.qq.com/cgi-bin/webhook/send/?key=BOTKEY&data=123\",\n        {\n            # another variation (more parameters don't obstruct our key)\n            \"instance\": NotifyWeComBot,\n        },\n    ),\n    (\n        \"wecombot://botkey\",\n        {\n            \"instance\": NotifyWeComBot,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"wecombot://botkey\",\n        {\n            \"instance\": NotifyWeComBot,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"wecombot://botkey\",\n        {\n            \"instance\": NotifyWeComBot,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_wecombot_urls():\n    \"\"\"NotifyWeComBot() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_whatsapp.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, NotifyType\nfrom apprise.plugins.whatsapp import NotifyWhatsApp\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"whatsapp://\",\n        {\n            # Not enough details\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://:@/\",\n        {\n            # invalid Access Token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://{}@_\".format(\"a\" * 32),\n        {\n            # token provided but invalid from\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://%20:{}@12345/{}\".format(\"e\" * 32, \"4\" * 11),\n        {\n            # Invalid template\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://{}@{}\".format(\"b\" * 32, 10**9),\n        {\n            # token provided and from but no target no\n            \"instance\": NotifyWhatsApp,\n            # Response will fail due to no targets defined\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"whatsapp://{}:{}@{}/123/{}/abcd/\".format(\n            \"a\" * 32, \"b\" * 32, \"3\" * 11, \"9\" * 15\n        ),\n        {\n            # valid everything but target numbers\n            \"instance\": NotifyWhatsApp,\n            # Response will fail due to target not being loaded\n            \"notify_response\": False,\n        },\n    ),\n    (\n        \"whatsapp://{}@12345/{}\".format(\"e\" * 32, \"4\" * 11),\n        {\n            # simple message\n            \"instance\": NotifyWhatsApp,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"whatsapp://e...e@1...5/%2B44444444444/\",\n        },\n    ),\n    (\n        \"whatsapp://template:{}@12345/{}\".format(\"e\" * 32, \"4\" * 11),\n        {\n            # template\n            \"instance\": NotifyWhatsApp,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"whatsapp://template:e...e@1...5/%2B44444444444/\",\n        },\n    ),\n    (\n        \"whatsapp://template:{}@12345/{}?lang=fr_CA\".format(\n            \"e\" * 32, \"4\" * 11\n        ),\n        {\n            # template with language over-ride\n            \"instance\": NotifyWhatsApp,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"whatsapp://template:e...e@1...5/%2B44444444444/\",\n        },\n    ),\n    (\n        \"whatsapp://{}@12345/{}?template=template&lang=fr_CA\".format(\n            \"e\" * 32, \"4\" * 11\n        ),\n        {\n            # template specified as kwarg with language over-ride\n            \"instance\": NotifyWhatsApp,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"whatsapp://template:e...e@1...5/%2B44444444444/\",\n        },\n    ),\n    (\n        \"whatsapp://template:{}@12345/{}?lang=1234\".format(\"e\" * 32, \"4\" * 11),\n        {\n            # template with invalid language over-ride\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://template:{}@12345/{}?:1=test&:body=3&:type=2\".format(\n            \"e\" * 32, \"4\" * 11\n        ),\n        {\n            # template with kwarg assignments\n            # {{1}} assigned test\n            # {{2}} assigned Apprise Message type (special keyword)\n            # {{3}} assigned Apprise Message body (special keyword)\n            \"instance\": NotifyWhatsApp,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"whatsapp://template:e...e@1...5/%2B44444444444/\",\n        },\n    ),\n    (\n        \"whatsapp://template:{}@12345/{}?:invalid=23\".format(\n            \"e\" * 32, \"4\" * 11\n        ),\n        {\n            # template with kwarg assignments\n            # Invalid keyword specified; cna only be a digit OR `body'\n            # or 'type'\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://template:{}@12345/{}?:body=\".format(\"e\" * 32, \"4\" * 11),\n        {\n            # template with kwarg assignments\n            # No Body Assigment\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://template:{}@12345/{}?:1=Test&:body=1\".format(\n            \"e\" * 32, \"4\" * 11\n        ),\n        {\n            # template with kwarg assignments\n            # Ambiguious assignment {{1}} assigned twice\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"whatsapp://{}:{}@123456/{}\".format(\"a\" * 32, \"b\" * 32, \"4\" * 11),\n        {\n            # using short-code (6 characters)\n            \"instance\": NotifyWhatsApp,\n        },\n    ),\n    (\n        \"whatsapp://_?token={}&from={}&to={}\".format(\n            \"d\" * 32, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyWhatsApp,\n        },\n    ),\n    (\n        \"whatsapp://_?token={}&source={}&to={}\".format(\n            \"d\" * 32, \"5\" * 11, \"6\" * 11\n        ),\n        {\n            # use get args to acomplish the same thing (use source instead\n            # of from)\n            \"instance\": NotifyWhatsApp,\n        },\n    ),\n    (\n        \"whatsapp://{}@12345/{}\".format(\"e\" * 32, \"4\" * 11),\n        {\n            \"instance\": NotifyWhatsApp,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"whatsapp://{}@12345/{}\".format(\"e\" * 32, \"4\" * 11),\n        {\n            \"instance\": NotifyWhatsApp,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_whatsapp_urls():\n    \"\"\"NotifyWhatsApp() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_whatsapp_auth(mock_post):\n    \"\"\"NotifyWhatsApp() Auth.\n\n    - account-wide auth token\n    - API key and its own auth token\n    \"\"\"\n\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    token = \"{}\".format(\"b\" * 32)\n    from_phone_id = \"123456787654321\"\n    target = \"+1 (555) 987-6543\"\n    message_contents = \"test\"\n\n    # Variation of initialization without API key\n    obj = Apprise.instantiate(f\"whatsapp://{token}@{from_phone_id}/{target}\")\n    assert isinstance(obj, NotifyWhatsApp) is True\n    assert isinstance(obj.url(), str) is True\n\n    # Send Notification\n    assert obj.send(body=message_contents) is True\n\n    # Validate expected call parameters\n    assert mock_post.call_count == 1\n    first_call = mock_post.call_args_list[0]\n\n    # URL and message parameters are the same for both calls\n    assert (\n        first_call[0][0]\n        == f\"https://graph.facebook.com/v17.0/{from_phone_id}/messages\"\n    )\n    response = loads(first_call[1][\"data\"])\n    assert response[\"text\"][\"body\"] == message_contents\n    assert response[\"to\"] == \"+15559876543\"\n    assert response[\"recipient_type\"] == \"individual\"\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_whatsapp_edge_cases(mock_post):\n    \"\"\"NotifyWhatsApp() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    token = \"b\" * 32\n    from_phone_id = \"123456787654321\"\n    targets = (\"+1 (555) 123-3456\",)\n\n    # No token specified\n    with pytest.raises(TypeError):\n        NotifyWhatsApp(\n            token=None, from_phone_id=from_phone_id, targets=targets\n        )\n\n    # No from_phone_id specified\n    with pytest.raises(TypeError):\n        NotifyWhatsApp(token=token, from_phone_id=None, targets=targets)\n\n    # a error response\n    response.status_code = 400\n    response.content = dumps({\n        \"error\": {\n            \"code\": 21211,\n            \"message\": \"The 'To' number +1234567 is not a valid phone number.\",\n        },\n    })\n    mock_post.return_value = response\n\n    # Initialize our object\n    obj = NotifyWhatsApp(\n        token=token, from_phone_id=from_phone_id, targets=targets\n    )\n\n    # We will fail with the above error code\n    assert obj.notify(\"title\", \"body\", \"info\") is False\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_whatsapp_template_notify_type_value(mock_post):\n    response = mock.Mock()\n    response.content = \"\"\n    response.status_code = requests.codes.ok\n    mock_post.return_value = response\n\n    token = \"b\" * 32\n    from_phone_id = \"12345\"\n    target = \"+1 (555) 987-6543\"\n\n    # Map notify_type -> {{2}}, body -> {{3}}\n    obj = Apprise.instantiate(\n        f\"whatsapp://template:{token}@{from_phone_id}/\"\n        f\"{target}?:type=2&:body=3\"\n    )\n    assert isinstance(obj, NotifyWhatsApp)\n\n    assert obj.send(\n        body=\"test\", title=\"t\", notify_type=NotifyType.INFO) is True\n\n    call = mock_post.call_args_list[0]\n    assert \"NotifyType.\" not in call[1][\"data\"]\n\n    payload = loads(call[1][\"data\"])\n    params = payload[\"template\"][\"components\"][0][\"parameters\"]\n\n    # Ensure values are injected, not literal strings\n    assert params[0][\"text\"] == NotifyType.INFO.value\n    assert params[1][\"text\"] == \"test\"\n"
  },
  {
    "path": "tests/test_plugin_windows.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom importlib import reload\n\n# Disable logging for a cleaner testing output\nimport logging\nimport sys\nimport types\nfrom unittest import mock\n\nimport pytest\n\nimport apprise\n\nlogging.disable(logging.CRITICAL)\n\n\n@pytest.mark.skipif(\n    (\n        \"win32api\" in sys.modules\n        or \"win32con\" in sys.modules\n        or \"win32gui\" in sys.modules\n    ),\n    reason=\"Requires non-windows platform\",\n)\ndef test_plugin_windows_mocked():\n    \"\"\"NotifyWindows() General Checks (via non-Windows platform)\"\"\"\n\n    # We need to fake our windows environment for testing purposes\n    win32api_name = \"win32api\"\n    win32api = types.ModuleType(win32api_name)\n    sys.modules[win32api_name] = win32api\n    win32api.GetModuleHandle = mock.Mock(\n        name=win32api_name + \".GetModuleHandle\"\n    )\n    win32api.PostQuitMessage = mock.Mock(\n        name=win32api_name + \".PostQuitMessage\"\n    )\n\n    win32con_name = \"win32con\"\n    win32con = types.ModuleType(win32con_name)\n    sys.modules[win32con_name] = win32con\n    win32con.CW_USEDEFAULT = mock.Mock(name=win32con_name + \".CW_USEDEFAULT\")\n    win32con.IDI_APPLICATION = mock.Mock(\n        name=win32con_name + \".IDI_APPLICATION\"\n    )\n    win32con.IMAGE_ICON = mock.Mock(name=win32con_name + \".IMAGE_ICON\")\n    win32con.LR_DEFAULTSIZE = 1\n    win32con.LR_LOADFROMFILE = 2\n    win32con.WM_DESTROY = mock.Mock(name=win32con_name + \".WM_DESTROY\")\n    win32con.WM_USER = 0\n    win32con.WS_OVERLAPPED = 1\n    win32con.WS_SYSMENU = 2\n\n    win32gui_name = \"win32gui\"\n    win32gui = types.ModuleType(win32gui_name)\n    sys.modules[win32gui_name] = win32gui\n    win32gui.CreateWindow = mock.Mock(name=win32gui_name + \".CreateWindow\")\n    win32gui.DestroyWindow = mock.Mock(name=win32gui_name + \".DestroyWindow\")\n    win32gui.LoadIcon = mock.Mock(name=win32gui_name + \".LoadIcon\")\n    win32gui.LoadImage = mock.Mock(name=win32gui_name + \".LoadImage\")\n    win32gui.NIF_ICON = 1\n    win32gui.NIF_INFO = mock.Mock(name=win32gui_name + \".NIF_INFO\")\n    win32gui.NIF_MESSAGE = 2\n    win32gui.NIF_TIP = 4\n    win32gui.NIM_ADD = mock.Mock(name=win32gui_name + \".NIM_ADD\")\n    win32gui.NIM_DELETE = mock.Mock(name=win32gui_name + \".NIM_DELETE\")\n    win32gui.NIM_MODIFY = mock.Mock(name=win32gui_name + \".NIM_MODIFY\")\n    win32gui.RegisterClass = mock.Mock(name=win32gui_name + \".RegisterClass\")\n    win32gui.UnregisterClass = mock.Mock(\n        name=win32gui_name + \".UnregisterClass\"\n    )\n    win32gui.Shell_NotifyIcon = mock.Mock(\n        name=win32gui_name + \".Shell_NotifyIcon\"\n    )\n    win32gui.UpdateWindow = mock.Mock(name=win32gui_name + \".UpdateWindow\")\n    win32gui.WNDCLASS = mock.Mock(name=win32gui_name + \".WNDCLASS\")\n\n    # The following allows our mocked content to kick in. In python 3.x keys()\n    # returns an iterator, therefore we need to convert the keys() back into\n    # a list object to prevent from getting the error:\n    #    \"RuntimeError: dictionary changed size during iteration\"\n    #\n    for mod in list(sys.modules.keys()):\n        if mod.startswith(\"apprise.\"):\n            del sys.modules[mod]\n    reload(apprise)\n\n    # Create our instance\n    obj = apprise.Apprise.instantiate(\"windows://\", suppress_exceptions=False)\n    obj.duration = 0\n\n    # Test URL functionality\n    assert isinstance(obj.url(), str)\n\n    # Verify that a URL ID can not be generated\n    assert obj.url_id() is None\n\n    # Check that it found our mocked environments\n    assert obj.enabled is True\n\n    # _on_destroy check\n    obj._on_destroy(0, \"\", 0, 0)\n\n    # test notifications\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?image=True\", suppress_exceptions=False\n    )\n    obj.duration = 0\n    assert isinstance(obj.url(), str)\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?image=False\", suppress_exceptions=False\n    )\n    obj.duration = 0\n    assert isinstance(obj.url(), str)\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=1\", suppress_exceptions=False\n    )\n    assert isinstance(obj.url(), str)\n    # loads okay\n    assert obj.duration == 1\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=invalid\", suppress_exceptions=False\n    )\n    # Falls back to default\n    assert obj.duration == obj.default_popup_duration_sec\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=-1\", suppress_exceptions=False\n    )\n    # Falls back to default\n    assert obj.duration == obj.default_popup_duration_sec\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=0\", suppress_exceptions=False\n    )\n    # Falls back to default\n    assert obj.duration == obj.default_popup_duration_sec\n\n    # To avoid slowdowns (for testing), turn it to zero for now\n    obj.duration = 0\n\n    # Test our loading of our icon exception; it will still allow the\n    # notification to be sent\n    win32gui.LoadImage.side_effect = AttributeError\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n    # Undo our change\n    win32gui.LoadImage.side_effect = None\n\n    # Test our global exception handling\n    win32gui.UpdateWindow.side_effect = AttributeError\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n    # Undo our change\n    win32gui.UpdateWindow.side_effect = None\n\n    # Toggle our testing for when we can't send notifications because the\n    # package has been made unavailable to us\n    obj.enabled = False\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n\n\n@pytest.mark.skipif(\n    \"win32api\" not in sys.modules\n    and \"win32con\" not in sys.modules\n    and \"win32gui\" not in sys.modules,\n    reason=\"Requires win32api, win32con, and win32gui\",\n)\n@mock.patch(\"win32gui.UpdateWindow\")\n@mock.patch(\"win32gui.Shell_NotifyIcon\")\n@mock.patch(\"win32gui.LoadImage\")\ndef test_plugin_windows_native(\n    mock_loadimage, mock_notify, mock_update_window\n):\n    \"\"\"NotifyWindows() General Checks (via Windows platform)\"\"\"\n\n    # Create our instance\n    obj = apprise.Apprise.instantiate(\"windows://\", suppress_exceptions=False)\n    obj.duration = 0\n\n    # Test URL functionality\n    assert isinstance(obj.url(), str)\n\n    # Check that it found our mocked environments\n    assert obj.enabled is True\n\n    # _on_destroy check\n    obj._on_destroy(0, \"\", 0, 0)\n\n    # test notifications\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?image=True\", suppress_exceptions=False\n    )\n    obj.duration = 0\n    assert isinstance(obj.url(), str)\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?image=False\", suppress_exceptions=False\n    )\n    obj.duration = 0\n    assert isinstance(obj.url(), str)\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=1\", suppress_exceptions=False\n    )\n    assert isinstance(obj.url(), str)\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n    # loads okay\n    assert obj.duration == 1\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=invalid\", suppress_exceptions=False\n    )\n    # Falls back to default\n    assert obj.duration == obj.default_popup_duration_sec\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=-1\", suppress_exceptions=False\n    )\n    # Falls back to default\n    assert obj.duration == obj.default_popup_duration_sec\n\n    obj = apprise.Apprise.instantiate(\n        \"windows://_/?duration=0\", suppress_exceptions=False\n    )\n    # Falls back to default\n    assert obj.duration == obj.default_popup_duration_sec\n\n    # To avoid slowdowns (for testing), turn it to zero for now\n    obj = apprise.Apprise.instantiate(\"windows://\", suppress_exceptions=False)\n    obj.duration = 0\n\n    # Test our loading of our icon exception; it will still allow the\n    # notification to be sent\n    mock_loadimage.side_effect = AttributeError\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is True\n    )\n    # Undo our change\n    mock_loadimage.side_effect = None\n\n    # Test our global exception handling\n    mock_update_window.side_effect = AttributeError\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n    # Undo our change\n    mock_update_window.side_effect = None\n\n    # Toggle our testing for when we can't send notifications because the\n    # package has been made unavailable to us\n    obj.enabled = False\n    assert (\n        obj.notify(\n            title=\"title\", body=\"body\", notify_type=apprise.NotifyType.INFO\n        )\n        is False\n    )\n"
  },
  {
    "path": "tests/test_plugin_workflows.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom inspect import cleandoc\nimport json\n\n# Disable logging for a cleaner testing output\nimport logging\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise import Apprise, AppriseConfig, NotifyType\nfrom apprise.plugins.workflows import NotifyWorkflows\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    ##################################\n    # NotifyWorkflows\n    ##################################\n    (\n        \"workflow://\",\n        {\n            # invalid host details (parsing fails very early)\n            \"instance\": None,\n        },\n    ),\n    (\n        \"workflow://:@/\",\n        {\n            # invalid host details (parsing fails very early)\n            \"instance\": None,\n        },\n    ),\n    (\n        \"workflow://host/workflow\",\n        {\n            # workflow provided only, no signature\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"workflow://host:443/^(/signature\",\n        {\n            # invalid workflow provided\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"workflow://host:443/workflow1a/signature/?image=no\",\n        {\n            # All tokens provided - we're good\n            # Tests case without image defined\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/workflow1b/signature/\",\n        {\n            # support workflows (s added to end)\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/signature/?id=workflow1c\",\n        {\n            # id= to store workflow id\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/signature/?workflow=workflow1d&wrap=yes\",\n        {\n            # workflow= to store workflow id\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/signature/?workflow=workflow1d&wrap=no\",\n        {\n            # workflow= to store workflow id\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/workflow1e/signature/?api-version=2024-01-01\",\n        {\n            # support api-version which is extracted from webhook\n            \"instance\": NotifyWorkflows,\n            # Our expected url(privacy=True) startswith() response\n            \"privacy_url\": \"workflow://host:443/w...e/s...e/\",\n        },\n    ),\n    (\n        \"workflows://host:443/workflow1b/signature/?ver=2016-06-01\",\n        {\n            # Support ver= (api-version alias)\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/?id=workflow1b&signature=signature\",\n        {\n            # Support signature= (sig= alias)\n            \"instance\": NotifyWorkflows,\n            # Our expected url(privacy=True) startswith() response\n            \"privacy_url\": \"workflow://host:443/w...b/s...e/\",\n        },\n    ),\n    (\n        \"workflows://host:443/workflow1e/signature/?powerautomate=yes\",\n        {\n            # support power_automate flag\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/workflow1e/signature/?pa=yes&ver=1995-01-01\",\n        {\n            # support power_automate flag with ver flag\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflows://host:443/workflow1e/signature/?pa=yes\",\n        {\n            # support power_automate flag\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    # Support native URLs\n    (\n        (\n            \"https://server.azure.com:443/workflows/643e69f83c8944/\"\n            \"triggers/manual/paths/invoke?\"\n            \"api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&\"\n            \"sv=1.0&sig=KODuebWbDGYFr0z0eu\"\n        ),\n        {\n            # All tokens provided - we're good\n            \"instance\": NotifyWorkflows,\n            # Our expected url(privacy=True) startswith() response\n            \"privacy_url\": \"workflow://server.azure.com:443/6...4/K...u/\",\n        },\n    ),\n    (\n        (\n            \"https://server.azure.com:443/\"\n            \"powerautomate/automations/direct/\"\n            \"workflows/643e69f83c8944/\"\n            \"triggers/manual/paths/invoke?\"\n            \"api-version=2022-03-01-preview&sp=%2Ftriggers%2Fmanual%2Frun&\"\n            \"sv=1.0&sig=KODuebWbDGYFr0z0eu\"\n        ),\n        {\n            # Power-Automate alternative URL - All tokens provided - we're good\n            \"instance\": NotifyWorkflows,\n        },\n    ),\n    (\n        \"workflow://host:443/workflow2/signature/\",\n        {\n            \"instance\": NotifyWorkflows,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"workflow://host:443/workflow3/signature/\",\n        {\n            \"instance\": NotifyWorkflows,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"workflow://host:443/workflow4/signature/\",\n        {\n            \"instance\": NotifyWorkflows,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_workflows_urls():\n    \"\"\"NotifyWorkflows() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@pytest.fixture\ndef workflows_url():\n    return \"workflow://host:443/workflow/signature\"\n\n\n@pytest.fixture\ndef request_mock(mocker):\n    \"\"\"Prepare requests mock.\"\"\"\n    mock_post = mocker.patch(\"requests.post\")\n    mock_post.return_value = requests.Request()\n    mock_post.return_value.status_code = requests.codes.ok\n    return mock_post\n\n\n@pytest.fixture\ndef simple_template(tmpdir):\n    template = tmpdir.join(\"simple.json\")\n    template.write(cleandoc(\"\"\"\n    {\n        \"type\": \"message\",\n        \"attachments\": [{\n            \"contentType\": \"application/vnd.microsoft.card.adaptive\",\n            \"contentUrl\": None,\n            \"content\": {\n                \"$schema\":\"http://adaptivecards.io/schemas/adaptive-card.json\",\n                \"type\": \"AdaptiveCard\",\n                \"version\": \"1.4\",\n                \"msteams\": { \"width\": \"full\" },\n                \"body\": [\n                    {\n                        \"type\": \"TextBlock\",\n                        \"text\": \"**Test**\",\n                        \"style\": \"heading\"\n                    },\n                ]\n            }\n        ]\n    }\n    \"\"\"))\n    return template\n\n\ndef test_plugin_workflows_simple_test(\n    request_mock, workflows_url,\n):\n    \"\"\"\n    NotifyWorkflows() simple testing\n    \"\"\"\n    # Instantiate our URL\n    obj = Apprise.instantiate(workflows_url)\n    assert isinstance(obj, NotifyWorkflows)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://host:443/workflows/workflow/triggers/manual/paths/invoke\"\n    )\n    payload = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"NotifyType.\" not in request_mock.call_args_list[0][1][\"data\"]\n    assert payload == {\n        \"type\": \"message\",\n        \"attachments\": [\n            {\n                \"contentType\": \"application/vnd.microsoft.card.adaptive\",\n                \"contentUrl\": None,\n                \"content\": {\n                    \"$schema\":\n                    \"http://adaptivecards.io/schemas/adaptive-card.json\",\n                    \"type\": \"AdaptiveCard\",\n                    \"version\": \"1.4\",\n                    \"body\": [\n                        {\n                            \"type\": \"Image\",\n                            \"url\": \"https://github.com/caronc/apprise/raw/\"\n                            \"master/apprise/assets/themes/default/\"\n                            \"apprise-info-32x32.png\",\n                            \"height\": \"32px\",\n                            \"altText\": NotifyType.INFO.value,\n                        }, {\n                            \"type\": \"TextBlock\",\n                            # Verify our Title is set\n                            \"text\": \"title\",\n                            \"style\": \"heading\",\n                            \"weight\": \"Bolder\",\n                            \"size\": \"Large\",\n                            \"id\": \"title\",\n                        }, {\n                            \"type\": \"TextBlock\",\n                            # Verify our Body is set\n                            \"text\": \"body\",\n                            \"style\": \"default\",\n                            \"wrap\": True,\n                            \"id\": \"body\",\n                        },\n                    ],\n                    \"msteams\": {\n                        \"width\": \"full\"\n                    },\n                },\n            },\n        ],\n    }\n\n    request_mock.reset_mock()\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(f\"{workflows_url}?pa=yes\")\n    assert isinstance(obj, NotifyWorkflows)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://host:443/powerautomate/automations/direct/\"\n        \"workflows/workflow/triggers/manual/paths/invoke\"\n    )\n    payload = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"NotifyType.\" not in request_mock.call_args_list[0][1][\"data\"]\n\n    assert payload == {\n        \"type\": \"message\",\n        \"attachments\": [\n            {\n                \"contentType\": \"application/vnd.microsoft.card.adaptive\",\n                \"contentUrl\": None,\n                \"content\": {\n                    \"$schema\": \"http://adaptivecards.io/schemas/\"\n                    \"adaptive-card.json\",\n                    \"type\": \"AdaptiveCard\",\n                    \"version\": \"1.4\",\n                    \"body\": [\n                        {\n                            \"type\": \"Image\",\n                            \"url\": \"https://github.com/caronc/apprise/raw/\"\n                            \"master/apprise/assets/themes/default/\"\n                            \"apprise-info-32x32.png\",\n                            \"height\": \"32px\",\n                            \"altText\": \"info\",\n                        }, {\n                            \"type\": \"TextBlock\",\n                            # Verify our Title is set\n                            \"text\": \"title\",\n                            \"style\": \"heading\",\n                            \"weight\": \"Bolder\",\n                            \"size\": \"Large\",\n                            \"id\": \"title\",\n                        }, {\n                            \"type\": \"TextBlock\",\n                            # Verify our Body is set\n                            \"text\": \"body\",\n                            \"style\": \"default\",\n                            \"wrap\": True,\n                            \"id\": \"body\",\n                        },\n                    ],\n                    \"msteams\": {\n                        \"width\": \"full\",\n                    },\n                },\n            },\n        ],\n    }\n\n\ndef test_plugin_workflows_templating_basic_success(\n    request_mock, workflows_url, tmpdir\n):\n    \"\"\"\n    NotifyWorkflows() Templating - success.\n    Test cases where URL and JSON is valid.\n    \"\"\"\n\n    template = tmpdir.join(\"simple.json\")\n    template.write(cleandoc(\"\"\"\n    {\n      \"@type\": \"MessageCard\",\n      \"@context\": \"https://schema.org/extensions\",\n      \"summary\": \"{{app_id}}\",\n      \"themeColor\": \"{{app_color}}\",\n      \"sections\": [\n        {\n          \"activityImage\": null,\n          \"activityTitle\": \"{{app_title}}\",\n          \"text\": \"{{app_body}}\"\n        }\n      ]\n    }\n    \"\"\"))\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=workflows_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token\",\n        )\n    )\n\n    assert isinstance(obj, NotifyWorkflows)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://host:443/workflows/workflow/triggers/manual/paths/invoke\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"NotifyType.\" not in request_mock.call_args_list[0][1][\"data\"]\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Apprise\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"title\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"body\"\n\n\ndef test_plugin_workflows_templating_invalid_json(\n    request_mock, workflows_url, tmpdir\n):\n    \"\"\"\n    NotifyWorkflows() Templating - invalid JSON.\n    \"\"\"\n\n    template = tmpdir.join(\"invalid.json\")\n    template.write(\"}\")\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=workflows_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token\",\n        )\n    )\n\n    assert isinstance(obj, NotifyWorkflows)\n    # We will fail to preform our notifcation because the JSON is bad\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n\n\ndef test_plugin_workflows_templating_load_json_failure(\n    request_mock, workflows_url, tmpdir\n):\n    \"\"\"\n    NotifyWorkflows() Templating - template loading failure.\n    Test a case where we can not access the file.\n    \"\"\"\n\n    template = tmpdir.join(\"empty.json\")\n    template.write(\"\")\n\n    obj = Apprise.instantiate(f\"{workflows_url}/?template={template!s}\")\n\n    with mock.patch(\"json.loads\", side_effect=OSError):\n        # we fail, but this time it's because we couldn't\n        # access the cached file contents for reading\n        assert (\n            obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n            is False\n        )\n\n\ndef test_plugin_workflows_templating_target_success(\n    request_mock, workflows_url, tmpdir\n):\n    \"\"\"\n    NotifyWorkflows() Templating - success with target.\n    A more complicated example; uses a target.\n    \"\"\"\n\n    template = tmpdir.join(\"more_complicated_example.json\")\n    template.write(cleandoc(\"\"\"\n    {\n      \"@type\": \"MessageCard\",\n      \"@context\": \"https://schema.org/extensions\",\n      \"summary\": \"{{app_desc}}\",\n      \"themeColor\": \"{{app_color}}\",\n      \"sections\": [\n        {\n          \"activityImage\": null,\n          \"activityTitle\": \"{{app_title}}\",\n          \"text\": \"{{app_body}}\"\n        }\n      ],\n     \"potentialAction\": [{\n        \"@type\": \"ActionCard\",\n        \"name\": \"Add a comment\",\n        \"inputs\": [{\n            \"@type\": \"TextInput\",\n            \"id\": \"comment\",\n            \"isMultiline\": false,\n            \"title\": \"Add a comment here for this task.\"\n        }],\n        \"actions\": [{\n            \"@type\": \"HttpPOST\",\n            \"name\": \"Add Comment\",\n            \"target\": \"{{ target }}\"\n        }]\n     }]\n    }\n    \"\"\"))\n\n    # Instantiate our URL\n    obj = Apprise.instantiate(\n        \"{url}/?template={template}&{kwargs}\".format(\n            url=workflows_url,\n            template=str(template),\n            kwargs=\":key1=token&:key2=token&:target=http://localhost\",\n        )\n    )\n\n    assert isinstance(obj, NotifyWorkflows)\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is True\n    )\n\n    assert request_mock.called is True\n    assert request_mock.call_args_list[0][0][0].startswith(\n        \"https://host:443/workflows/workflow/triggers/manual/paths/invoke\"\n    )\n\n    # Our Posted JSON Object\n    posted_json = json.loads(request_mock.call_args_list[0][1][\"data\"])\n    assert \"NotifyType.\" not in request_mock.call_args_list[0][1][\"data\"]\n    assert \"summary\" in posted_json\n    assert posted_json[\"summary\"] == \"Apprise Notifications\"\n    assert posted_json[\"themeColor\"] == \"#3AA3E3\"\n    assert posted_json[\"sections\"][0][\"activityTitle\"] == \"title\"\n    assert posted_json[\"sections\"][0][\"text\"] == \"body\"\n\n    # We even parsed our entry out of the URL\n    assert (\n        posted_json[\"potentialAction\"][0][\"actions\"][0][\"target\"]\n        == \"http://localhost\"\n    )\n\n\ndef test_workflows_yaml_config_missing_template_filename(\n    request_mock, workflows_url, simple_template, tmpdir\n):\n    \"\"\"\n    NotifyWorkflows() YAML Configuration Entries - Missing template reference.\n    \"\"\"\n\n    config = tmpdir.join(\"workflow01.yml\")\n    config.write(cleandoc(f\"\"\"\n    urls:\n      - {workflows_url}:\n        - tag: 'workflow'\n          template: {simple_template!s}.missing\n          :name: 'Template.Missing'\n          :body: 'test body'\n          :title: 'test title'\n    \"\"\"))\n\n    # Config still loads okay\n    cfg = AppriseConfig()\n    cfg.add(str(config))\n    assert len(cfg) == 1\n    assert len(cfg[0]) == 1\n\n    obj = cfg[0][0]\n    assert isinstance(obj, NotifyWorkflows)\n\n    # However we can't send notification since the template couldn't be loaded\n    assert (\n        obj.notify(body=\"body\", title=\"title\", notify_type=NotifyType.INFO)\n        is False\n    )\n    assert request_mock.called is False\n\n\ndef test_plugin_workflows_edge_cases():\n    \"\"\"NotifyWorkflows() Edge Cases.\"\"\"\n    # Initializes the plugin with an invalid token\n    with pytest.raises(TypeError):\n        NotifyWorkflows(workflow=\"@\", signature=\"@\")\n    with pytest.raises(TypeError):\n        NotifyWorkflows(workflow=\"\", signature=\"abcd\")\n\n    with pytest.raises(TypeError):\n        NotifyWorkflows(workflow=None, signature=\"abcd\")\n    # Whitespace also acts as an invalid token value\n    with pytest.raises(TypeError):\n        NotifyWorkflows(workflow=\"  \", signature=\"abcd\")\n\n    with pytest.raises(TypeError):\n        NotifyWorkflows(workflow=\"abcd\", signature=None)\n    # Whitespace also acts as an invalid token value\n    with pytest.raises(TypeError):\n        NotifyWorkflows(workflow=\"abcd\", signature=\"  \")\n\n    # test case where invalid tokens are specified\n    with pytest.raises(TypeError):\n        NotifyWorkflows(\n            workflow=\"workflow\", signature=\"signature\", tokens=\"not-a-dict\"\n        )\n\n    # test case where no tokens are specified\n    obj = NotifyWorkflows(workflow=\"workflow\", signature=\"signature\")\n    assert isinstance(obj, NotifyWorkflows)\n\n\ndef test_plugin_workflows_azure_webhooks(request_mock):\n    \"\"\"NotifyWorkflows() Azure Webhooks.\"\"\"\n    url = (\n        \"https://prod-15.uksouth.logic.azure.com:443\"\n        \"/workflows/3XXX5/triggers/manual/paths/invoke\"\n        \"?api-version=2016-06-01&\"\n        \"sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU\"\n    )\n\n    #\n    # Initialize\n    #\n    obj = Apprise.instantiate(url)\n    assert isinstance(obj, NotifyWorkflows)\n    assert obj.workflow == \"3XXX5\"\n    assert obj.signature == \"iXXXU\"\n    assert obj.api_version == \"2016-06-01\"\n"
  },
  {
    "path": "tests/test_plugin_wxpusher.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nfrom json import dumps, loads\n\n# Disable logging for a cleaner testing output\nimport logging\nimport os\nfrom unittest import mock\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import Apprise\nfrom apprise.plugins.wxpusher import NotifyWxPusher\n\nlogging.disable(logging.CRITICAL)\n\nWXPUSHER_GOOD_RESPONSE = dumps({\"code\": 1000})\nWXPUSHER_BAD_RESPONSE = dumps({\"code\": 99})\n\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"wxpusher://\",\n        {\n            # No token specified\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"wxpusher://:@/\",\n        {\n            # invalid url\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"wxpusher://invalid\",\n        {\n            # invalid app token\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/123/\",\n        {\n            # invalid 'to' phone number\n            \"instance\": NotifyWxPusher,\n            # Notify will fail because it couldn't send to anyone\n            \"response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxpusher://****/123/\",\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/%20/%20/\",\n        {\n            # invalid 'to' phone number\n            \"instance\": NotifyWxPusher,\n            # Notify will fail because it couldn't send to anyone\n            \"response\": False,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxpusher://****/\",\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/123/\",\n        {\n            # one phone number will notify ourselves\n            \"instance\": NotifyWxPusher,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://123?token=AT_abc1234\",\n        {\n            # pass our token in as an argument and our host actually becomes a\n            # target\n            \"instance\": NotifyWxPusher,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://?token=AT_abc1234\",\n        {\n            # slightly different then test above; a token is defined, but\n            # there are no targets\n            \"instance\": NotifyWxPusher,\n            # Notify will fail because it couldn't send to anyone\n            \"response\": False,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://?token=AT_abc1234&to=UID_abc\",\n        {\n            # all kwargs to load url with\n            \"instance\": NotifyWxPusher,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/UID_abcd/\",\n        {\n            # a valid contact\n            \"instance\": NotifyWxPusher,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxpusher://****/UID_abcd\",\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/@/#/,/\",\n        {\n            # Test case where we provide bad data\n            \"instance\": NotifyWxPusher,\n            # Our failed response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/123/\",\n        {\n            # Test case where we get a bad response\n            \"instance\": NotifyWxPusher,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxpusher://****/123\",\n            # Our failed response\n            \"requests_response_text\": WXPUSHER_BAD_RESPONSE,\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/UID_345/\",\n        {\n            # Test case where we get a bad response\n            \"instance\": NotifyWxPusher,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxpusher://****/UID_345\",\n            # Our failed response\n            \"requests_response_text\": None,\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/UID_345/\",\n        {\n            # Test case where we get a bad response\n            \"instance\": NotifyWxPusher,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"wxpusher://****/UID_345\",\n            # Our failed response (bad json)\n            \"requests_response_text\": \"{\",\n            # as a result, we expect a failed notification\n            \"response\": False,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/?to={},{}\".format(\"2\" * 11, \"3\" * 11),\n        {\n            # use get args to acomplish the same thing\n            \"instance\": NotifyWxPusher,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/?to={},{},{}\".format(\"2\" * 11, \"3\" * 11, \"5\" * 3),\n        {\n            # 2 good targets and one invalid one\n            \"instance\": NotifyWxPusher,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/{}/{}/\".format(\"2\" * 11, \"3\" * 11),\n        {\n            # If we have from= specified, then all elements take on the\n            # to= value\n            \"instance\": NotifyWxPusher,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/{}\".format(\"3\" * 11),\n        {\n            # use get args to acomplish the same thing (use source instead\n            # of from)\n            \"instance\": NotifyWxPusher,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/{}\".format(\"4\" * 11),\n        {\n            \"instance\": NotifyWxPusher,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n            # Our response expected server response\n            \"requests_response_text\": WXPUSHER_GOOD_RESPONSE,\n        },\n    ),\n    (\n        \"wxpusher://AT_appid/{}\".format(\"4\" * 11),\n        {\n            \"instance\": NotifyWxPusher,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_wxpusher_urls():\n    \"\"\"NotifyWxPusher() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_wxpusher_edge_cases(mock_post):\n    \"\"\"NotifyWxPusher() Edge Cases.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n    response.content = WXPUSHER_GOOD_RESPONSE\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    # Initialize some generic (but valid) tokens\n    target = \"UID_abcd\"\n    body = \"test body\"\n    title = \"My Title\"\n\n    aobj = Apprise()\n    assert aobj.add(f\"wxpusher://AT_appid/{target}\")\n    assert len(aobj) == 1\n    assert aobj.notify(title=title, body=body)\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"https://wxpusher.zjiecode.com/api/send/message\"\n    payload = loads(details[1][\"data\"])\n    assert payload == {\n        \"appToken\": \"AT_appid\",\n        \"content\": \"test body\",\n        \"summary\": \"My Title\",\n        \"contentType\": 1,\n        \"topicIds\": [],\n        \"uids\": [\"UID_abcd\"],\n        \"url\": None,\n    }\n\n    # Reset our mock object\n    mock_post.reset_mock()\n\n\n@mock.patch(\"requests.post\")\ndef test_plugin_wxpusher_result_set(mock_post):\n    \"\"\"NotifyWxPusher() Result Sets.\"\"\"\n\n    # Prepare our response\n    response = requests.Request()\n    response.status_code = requests.codes.ok\n    response.content = WXPUSHER_GOOD_RESPONSE\n\n    # Prepare Mock\n    mock_post.return_value = response\n\n    body = \"test body\"\n    title = \"My Title\"\n\n    aobj = Apprise()\n    aobj.add(\"wxpusher://AT_appid/123/abc/UID_456\")\n    # One bad entry and 2 good\n    assert len(aobj[0]) == 1\n\n    assert aobj.notify(title=title, body=body)\n\n    # 2 posts made\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"https://wxpusher.zjiecode.com/api/send/message\"\n    payload = loads(details[1][\"data\"])\n    assert payload == {\n        \"appToken\": \"AT_appid\",\n        \"content\": \"test body\",\n        \"summary\": \"My Title\",\n        \"contentType\": 1,\n        \"topicIds\": [123],\n        \"uids\": [\"UID_456\"],\n        \"url\": None,\n    }\n\n    # Validate our information is also placed back into the assembled URL\n    assert \"/123\" in aobj[0].url()\n    assert \"/UID_456\" in aobj[0].url()\n    assert \"/abc\" in aobj[0].url()\n\n    mock_post.reset_mock()\n\n    aobj = Apprise()\n    aobj.add(\"wxpusher://AT_appid//UID_123/UID_abc/123456789\")\n    assert len(aobj[0]) == 1\n\n    assert aobj.notify(title=title, body=body)\n\n    # If batch is off then there is a post per entry\n    assert mock_post.call_count == 1\n\n    details = mock_post.call_args_list[0]\n    assert details[0][0] == \"https://wxpusher.zjiecode.com/api/send/message\"\n    payload = loads(details[1][\"data\"])\n\n    assert payload == {\n        \"appToken\": \"AT_appid\",\n        \"content\": \"test body\",\n        \"summary\": \"My Title\",\n        \"contentType\": 1,\n        \"topicIds\": [123456789],\n        \"uids\": [\"UID_123\", \"UID_abc\"],\n        \"url\": None,\n    }\n\n    assert \"/123456789\" in aobj[0].url()\n    assert \"/UID_123\" in aobj[0].url()\n    assert \"/UID_abc\" in aobj[0].url()\n\n\n@mock.patch(\"requests.post\")\ndef test_notify_wxpusher_plugin_result_list(mock_post):\n    \"\"\"NotifyWxPusher() Result List Response.\"\"\"\n\n    okay_response = requests.Request()\n    okay_response.status_code = requests.codes.ok\n    # We want to test the case where the `result` set returned is a list\n\n    # Invalid JSON response\n    okay_response.content = \"{\"\n\n    # Assign our mock object our return value\n    mock_post.return_value = okay_response\n\n    obj = Apprise.instantiate(\"wxpusher://AT_apptoken/UID_abcd/\")\n    assert isinstance(obj, NotifyWxPusher)\n\n    # We should now fail\n    assert obj.notify(\"test\") is False\n"
  },
  {
    "path": "tests/test_plugin_xbmc_kodi.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport requests\n\nfrom apprise import NotifyType\nfrom apprise.plugins.kodi import NotifyXBMC\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"kodi://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"kodis://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"kodi://localhost\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"kodi://192.168.4.1\",\n        {\n            # Support IPv4 Addresses\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]\",\n        {\n            # Support IPv6 Addresses\n            \"instance\": NotifyXBMC,\n            # Privacy URL\n            \"privacy_url\": \"kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]\",\n        },\n    ),\n    (\n        \"kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8282\",\n        {\n            # Support IPv6 Addresses with port\n            \"instance\": NotifyXBMC,\n            # Privacy URL\n            \"privacy_url\": (\n                \"kodi://[2001:db8:002a:3256:adfe:05c0:0003:0006]:8282\"\n            ),\n        },\n    ),\n    (\n        \"kodi://user:pass@localhost\",\n        {\n            \"instance\": NotifyXBMC,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kodi://user:****@localhost\",\n        },\n    ),\n    (\n        \"kodi://localhost:8080\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"kodi://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"kodis://localhost\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"kodis://user:pass@localhost\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"kodis://localhost:8080/path/\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"kodis://user:password@localhost:8080\",\n        {\n            \"instance\": NotifyXBMC,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"kodis://user:****@localhost:8080\",\n        },\n    ),\n    (\n        \"kodi://localhost\",\n        {\n            \"instance\": NotifyXBMC,\n            # Experement with different notification types\n            \"notify_type\": NotifyType.WARNING,\n        },\n    ),\n    (\n        \"kodi://localhost\",\n        {\n            \"instance\": NotifyXBMC,\n            # Experement with different notification types\n            \"notify_type\": NotifyType.FAILURE,\n        },\n    ),\n    (\n        \"kodis://localhost:443\",\n        {\n            \"instance\": NotifyXBMC,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"kodi://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"kodi://user:pass@localhost:8081\",\n        {\n            \"instance\": NotifyXBMC,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"kodi://user:pass@localhost:8082\",\n        {\n            \"instance\": NotifyXBMC,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"kodi://user:pass@localhost:8083\",\n        {\n            \"instance\": NotifyXBMC,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n    #\n    # XMBC (Legacy Platform) Shares this same KODI Plugin\n    #\n    (\n        \"xbmc://\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"xbmc://localhost\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"xbmc://localhost?duration=14\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"xbmc://localhost?duration=invalid\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"xbmc://localhost?duration=-1\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"xbmc://user:pass@localhost\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"xbmc://localhost:8080\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"xbmc://user:pass@localhost:8080\",\n        {\n            \"instance\": NotifyXBMC,\n        },\n    ),\n    (\n        \"xbmc://user@localhost\",\n        {\n            \"instance\": NotifyXBMC,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"xbmc://localhost\",\n        {\n            \"instance\": NotifyXBMC,\n            # Experement with different notification types\n            \"notify_type\": NotifyType.WARNING,\n        },\n    ),\n    (\n        \"xbmc://localhost\",\n        {\n            \"instance\": NotifyXBMC,\n            # Experement with different notification types\n            \"notify_type\": NotifyType.FAILURE,\n        },\n    ),\n    (\n        \"xbmc://:@/\",\n        {\n            \"instance\": None,\n        },\n    ),\n    (\n        \"xbmc://user:pass@localhost:8081\",\n        {\n            \"instance\": NotifyXBMC,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"xbmc://user:pass@localhost:8082\",\n        {\n            \"instance\": NotifyXBMC,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"xbmc://user:pass@localhost:8083\",\n        {\n            \"instance\": NotifyXBMC,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_xbmc_kodi_urls():\n    \"\"\"NotifyXBMC() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n"
  },
  {
    "path": "tests/test_plugin_xmpp.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"\nUnit Tests for the XMPP plugin.\n\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nimport gc\nimport logging\nimport ssl\nimport threading\nimport time\nfrom types import SimpleNamespace\nfrom typing import Any, Optional\n\nimport pytest\n\nfrom apprise import LOGGER_NAME, Apprise, NotifyType\nfrom apprise.plugins.xmpp import adapter as xmpp_adapter, base as xmpp_base\nfrom apprise.plugins.xmpp.base import NotifyXMPP\n\n\ndef run_on_loop(loop: asyncio.AbstractEventLoop, coro: Any) -> Any:\n    \"\"\"Run a coroutine on a specific event loop.\"\"\"\n    asyncio.set_event_loop(loop)\n    return loop.run_until_complete(coro)\n\n\n# ---------------------------------------------------------------------------\n# Slixmpp Availability Handling\n# ---------------------------------------------------------------------------\n\ntry:\n    # Enforce slixmpp >= Minimum Supported Version\n    SLIXMPP_AVAILABLE = xmpp_adapter.SlixmppAdapter.supported_version()\n\nexcept Exception:\n    SLIXMPP_AVAILABLE = False\n\n# Seconds to sleep before forcing the timeout branch in _FakeDoneEvent.wait().\n# This gives the worker thread enough time to progress to a known checkpoint\n# (e.g. event loop created, client instantiated) without relying on a longer\n# wall-clock timeout that would slow tests down.\nWORKER_THREAD_STARTUP_DELAY: float = 0.02\n\n\n# ---------------------------------------------------------------------------\n# Fake Slixmpp Client\n# ---------------------------------------------------------------------------\n\nclass FakeClientXMPP:\n    def __init__(self, jid: str, password: str) -> None:\n        self.jid = jid\n        self.password = password\n        # Loop is assigned by adapter via `client.loop = loop`, but we default\n        # to the current event loop for safety in tests.\n        self.loop: Optional[asyncio.AbstractEventLoop] = None\n        self.handlers: dict[str, Any] = {}\n        self.auto_reconnect = True\n\n        # Slixmpp >= 1.10.0: adapter waits on this Future\n        self.disconnected: Optional[asyncio.Future[bool]] = None\n\n        # Slixmpp toggles used by adapter\n        self.enable_plaintext = True\n        self.enable_starttls = True\n        self.enable_direct_tls = False\n        self.ssl_context = None\n\n        # Track plugins registered by the adapter (keepalive path)\n        self.registered_plugins: dict[str, Any] = {}\n\n    def add_event_handler(self, name: str, handler: Any) -> None:\n        self.handlers[name] = handler\n\n    def send_presence(self) -> None:\n        return None\n\n    async def get_roster(self) -> None:\n        return None\n\n    def send_message(self, **kwargs: Any) -> None:\n        return None\n\n    def register_plugin(self, name: str, config: Optional[Any] = None) -> None:\n        self.registered_plugins[name] = config\n        return None\n\n    def disconnect(self) -> None:\n        # Complete the disconnected Future if it is not already done.\n        if self.disconnected is not None and not self.disconnected.done():\n            self.disconnected.set_result(True)\n\n    def connect(self, **kwargs: Any) -> asyncio.Future[bool]:\n        \"\"\"\n        Slixmpp >= 1.10.0 connect() returns a Future. Our adapter awaits it.\n\n        This fake schedules session_start on the assigned loop so the adapter's\n        loop.run_until_complete() drives it, and ensures disconnected resolves.\n        \"\"\"\n        loop = self.loop or asyncio.get_running_loop()\n\n        # Ensure disconnected future exists on the correct loop\n        if self.disconnected is None:\n            self.disconnected = loop.create_future()\n\n        fut: asyncio.Future[bool] = loop.create_future()\n        fut.set_result(True)\n\n        handler = self.handlers.get(\"session_start\")\n        if handler:\n            def _fire_session_start() -> None:\n                rv = handler()\n                if asyncio.iscoroutine(rv):\n                    task = loop.create_task(rv)\n                    # Ensure we always disconnect after session_start\n                    task.add_done_callback(lambda _: self.disconnect())\n                else:\n                    # sync handler, just disconnect\n                    self.disconnect()\n\n            # Schedule for when the adapter starts running the loop\n            loop.call_soon(_fire_session_start)\n\n        return fut\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n\ndef install_fake_slixmpp(monkeypatch: pytest.MonkeyPatch) -> None:\n    monkeypatch.setattr(xmpp_adapter, \"asyncio\", asyncio, raising=False)\n    monkeypatch.setattr(\n        xmpp_adapter,\n        \"slixmpp\",\n        SimpleNamespace(ClientXMPP=FakeClientXMPP),\n        raising=False,\n    )\n    monkeypatch.setattr(\n        xmpp_adapter, \"SLIXMPP_SUPPORT_AVAILABLE\", True, raising=False)\n    monkeypatch.setattr(\n        xmpp_adapter.SlixmppAdapter,\n        \"_enabled\",\n        True,\n        raising=False,\n    )\n\n\ndef _patch_threading(\n        monkeypatch: pytest.MonkeyPatch, *,\n        done_event_cls: type) -> None:\n    \"\"\"Patch adapter threading to use a custom done-event class.\"\"\"\n    import threading as _real_threading\n\n    class _ThreadingProxy:\n        Event = done_event_cls\n        Thread = _real_threading.Thread\n\n        def __getattr__(self, name: str) -> Any:\n            return getattr(_real_threading, name)\n\n    monkeypatch.setattr(\n        xmpp_adapter, \"threading\", _ThreadingProxy(), raising=True)\n\n\n# ---------------------------------------------------------------------------\n# NotifyXMPP Tests\n# ---------------------------------------------------------------------------\n\ndef _make_fake_done_event_cls(\n        signal_evt: Optional[threading.Event] = None,\n) -> type:\n    \"\"\"Return a deterministic threading.Event replacement for timeout tests.\n\n    The adapter under test uses ``threading.Event()`` for *done*, then calls\n    ``done.wait(timeout=...)`` and later ``done.set()`` from the worker thread.\n\n    These tests want ``wait()`` to return ``False`` deterministically (forcing\n    the timeout branch), while still giving the worker thread a chance to\n    reach a known checkpoint first (e.g. event loop created, client created).\n\n    Capturing *signal_evt* in a closure rather than as a class-level attribute\n    ensures that each test's event class is fully independent — parallel test\n    runs cannot interfere with one another through shared mutable class state.\n\n    Parameters\n    ----------\n    signal_evt:\n        Optional ``threading.Event`` that the worker thread sets when it\n        reaches a stable checkpoint.  If provided, ``wait()`` pauses until\n        that event is set before forcing the timeout branch.\n    \"\"\"\n\n    class _FakeDoneEvent:\n        def __init__(self) -> None:\n            self._set = False\n            self._wait_calls = 0\n\n        def is_set(self) -> bool:\n            return self._set\n\n        def set(self) -> None:\n            self._set = True\n\n        def clear(self) -> None:\n            self._set = False\n\n        def wait(self, timeout: Optional[float] = None) -> bool:\n            self._wait_calls += 1\n            if self._wait_calls == 1:\n                if signal_evt is not None:\n                    # The specific timeout value isn't important; we just\n                    # don't want this to hang if the checkpoint is never\n                    # reached.\n                    signal_evt.wait(timeout=1.0)\n\n                # Give the worker thread a brief chance to run before we\n                # force the timeout condition in the code under test.\n                time.sleep(WORKER_THREAD_STARTUP_DELAY)\n                return False\n            return self._set\n\n    return _FakeDoneEvent\n\n\n@pytest.mark.skipif(SLIXMPP_AVAILABLE, reason=\"Requires slixmpp NOT installed\")\ndef test_slixmpp_unavailable() -> None:\n    obj = NotifyXMPP(host=\"example.com\", user=\"me@example.com\", password=\"x\")\n    assert obj.send(\"will fail\") is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_invalid_jid() -> None:\n    with pytest.raises(TypeError):\n        NotifyXMPP(host=\"example.com\", user=\"bad jid\", password=\"x\")\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_targets_filtered() -> None:\n    n = NotifyXMPP(\n        host=\"example.com\",\n        user=\"me@example.com\",\n        password=\"x\",\n        targets=[\"ok@example.com\", \" user1 user2 \", \"\", \"also@example.net\"],\n    )\n\n    # parse_list() tokenises on whitespace, so \"bad jid\" becomes two targets.\n    # Empty values are dropped before they reach the validation loop, so no\n    # warning is guaranteed for the empty string.\n    assert sorted(n.targets) == [\n        \"also@example.net\",\n        \"ok@example.com\",\n        \"user1@example.com\",\n        \"user2@example.com\",\n    ]\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_len() -> None:\n    n1 = NotifyXMPP(host=\"example.com\", user=\"me@example.com\", password=\"x\")\n    assert len(n1) == 1\n\n    n2 = NotifyXMPP(\n        host=\"example.com\",\n        user=\"me@example.com\",\n        password=\"x\",\n        targets=[\"a@example.com\", \"b@example.com\"],\n    )\n    # still just one upstream message\n    assert len(n2) == 1\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_url_privacy() -> None:\n    n = NotifyXMPP(\n        host=\"example.com\",\n        user=\"me@example.com\",\n        password=\"secret\",\n        targets=[\"a@example.com\"],\n        verify=False,\n    )\n    u = n.url(privacy=True)\n    assert \"secret\" not in u\n    assert \"verify=\" in u\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_parse_url() -> None:\n    r = NotifyXMPP.parse_url(\n        \"xmpps://me:pass@example.com/a@example.com?verify=no\"\n    )\n    assert r[\"verify\"] is False\n    assert r[\"targets\"] == [\"a@example.com\"]\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_parse_url_to() -> None:\n    r = NotifyXMPP.parse_url(\n        \"xmpp://me:pass@example.com?to=a@example.com,b@example.net\"\n    )\n    assert sorted(r[\"targets\"]) == [\"a@example.com\", \"b@example.net\"]\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_invalid_targets_logged(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    \"\"\"Invalid XMPP targets are dropped with a warning.\"\"\"\n\n    caplog.set_level(logging.WARNING, logger=LOGGER_NAME)\n    caplog.set_level(logging.WARNING, logger=xmpp_base.__name__)\n\n    # parse_list() tokenises on whitespace, so it is difficult to naturally\n    # feed a whitespace-containing value through.  Patch parse_list() to\n    # return a value that will fail IS_JID validation.\n    monkeypatch.setattr(\n        xmpp_base,\n        \"parse_list\",\n        lambda _v: [\"bad jid\", \"ok@example.com\"],\n        raising=True,\n    )\n\n    n = NotifyXMPP(\n        host=\"example.com\",\n        user=\"me@example.com\",\n        password=\"x\",\n        targets=[\"ignored\"],\n    )\n\n    assert n.targets == [\"ok@example.com\"]\n    assert \"Dropped invalid XMPP target\" in caplog.text\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_url_identifier_is_accessible() -> None:\n    \"\"\"url_identifier property should be accessible and stable.\"\"\"\n    n = NotifyXMPP(\n        host=\"example.com\", user=\"me@example.com\", password=\"secret\")\n    schema, host, user, password, port = n.url_identifier\n    assert schema == \"xmpp\"\n    assert host == \"example.com\"\n    assert user == \"me@example.com\"\n    assert password == \"secret\"\n    assert port is None\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_url_handling() -> None:\n    \"\"\"url() must not append a path when there are no targets.\"\"\"\n    apobj = Apprise()\n    assert apobj.add(\"xmpp://me:secret@example.com?verify=no\") is True\n\n    assert len(apobj) == 1\n    plugin = apobj[0]\n\n    u = plugin.url(privacy=False)\n    assert \"example.com/?\" in u\n    assert \"mode=none\" in u\n\n    apobj = Apprise()\n    assert apobj.add(\"xmpps://me:secret@example.com?mode=tls\") is True\n\n    assert len(apobj) == 1\n    plugin = apobj[0]\n\n    u = plugin.url(privacy=False)\n    assert \"mode=tls\" in u\n\n    # invalid Mode\n    apobj = Apprise()\n    assert apobj.add(\"xmpps://me:secret@example.com?mode=invalid\") is False\n\n    # Ambiguous secure=true (xmpps://) and mode = none\n    apobj = Apprise()\n    assert apobj.add(\"xmpps://me:secret@example.com?mode=none\") is True\n\n    assert len(apobj) == 1\n    plugin = apobj[0]\n\n    u = plugin.url(privacy=False)\n    # starttls (upgraded from none - most secure path)\n    assert \"mode=starttls\" in u\n\n    # Ambiguous secure=False (xmpp://) and mode != none\n    apobj = Apprise()\n    assert apobj.add(\"xmpp://me:secret@example.com?mode=tls\") is True\n\n    assert len(apobj) == 1\n    plugin = apobj[0]\n\n    u = plugin.url(privacy=False)\n    # most secure path prevails\n    assert \"mode=tls\" in u\n\n    apobj = Apprise()\n    assert apobj.add(\n        \"xmpp://me:secret@example.com?subject=yes&roster=yes\") is True\n\n    assert len(apobj) == 1\n    plugin = apobj[0]\n\n    u = plugin.url(privacy=False)\n    # most secure path prevails\n    assert \"mode=none\" in u\n    assert \"roster=yes\" in u\n    assert \"subject=yes\" in u\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_parse_url_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"parse_url() returns None when NotifyBase.parse_url() fails.\"\"\"\n\n    monkeypatch.setattr(\n        xmpp_base.NotifyBase,\n        \"parse_url\",\n        lambda *_args, **_kwargs: None,\n        raising=True,\n    )\n    assert NotifyXMPP.parse_url(\"xmpp://bad\") is None\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_finalize_loop_when_already_closed_does_not_close_again() -> None:\n    loop = asyncio.new_event_loop()\n    loop.close()\n\n    # If _finalize_loop tried to close again, this would still be harmless,\n    # but this test specifically forces the \"if not loop.is_closed()\" branch\n    # to be False for coverage.\n    xmpp_adapter.SlixmppAdapter._finalize_loop(loop)\n\n    assert loop.is_closed()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_apprise_notify_invokes_adapter(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"Exercise the Apprise.notify() workflow while mocking network I/O.\"\"\"\n\n    captured: dict[str, Any] = {}\n\n    class _Adapter:\n        def __init__(\n            self,\n            config: xmpp_adapter.XMPPConfig,\n            targets: list[str],\n            subject: str,\n            body: str,\n            timeout: float = 0.0,\n            roster: bool = False,\n            before_message: Any = None,\n            logger: Optional[logging.Logger] = None,\n            **kwargs: Any,\n        ) -> None:\n            captured[\"targets\"] = targets\n            captured[\"body\"] = body\n            captured[\"subject\"] = subject\n            captured[\"roster\"] = roster\n\n        def process(self) -> bool:\n            return True\n\n    monkeypatch.setattr(\n        xmpp_base, \"SlixmppAdapter\", _Adapter, raising=True)\n\n    apobj = Apprise()\n    apobj.add(\"xmpp://me:secret@example.com/a@example.com\")\n\n    assert apobj.notify(\n        \"hello\", title=\"subject\", notify_type=NotifyType.INFO) is True\n    assert captured[\"targets\"] == [\"a@example.com\"]\n    assert captured[\"subject\"] == \"\"\n    assert \"subject\\r\\nhello\" in captured[\"body\"]\n\n    apobj = Apprise()\n    apobj.add(\"xmpp://me:secret@example.com/a@example.com?subject=yes\")\n\n    assert apobj.notify(\n        \"hello\", title=\"subject\", notify_type=NotifyType.INFO) is True\n    assert captured[\"targets\"] == [\"a@example.com\"]\n    assert captured[\"subject\"] == \"subject\"\n    assert \"hello\" in captured[\"body\"]\n\n# ---------------------------------------------------------------------------\n# Adapter Tests\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_ssl_context() -> None:\n    ctx = xmpp_adapter.SlixmppAdapter._ssl_context(True)\n    assert ctx.verify_mode != ssl.CERT_NONE\n\n    ctx2 = xmpp_adapter.SlixmppAdapter._ssl_context(False)\n    assert ctx2.verify_mode == ssl.CERT_NONE\n    assert ctx2.check_hostname is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_cancel_pending_empty(monkeypatch: pytest.MonkeyPatch) -> None:\n    loop = asyncio.new_event_loop()\n    try:\n        monkeypatch.setattr(\n            asyncio, \"all_tasks\", lambda _: set(), raising=True)\n        xmpp_adapter.SlixmppAdapter._finalize_loop(loop)\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_cancel_pending_tasks(monkeypatch: pytest.MonkeyPatch) -> None:\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n\n        async def _noop() -> None:\n            await asyncio.sleep(0)\n\n        t1 = loop.create_task(_noop())\n        t2 = loop.create_task(_noop())\n\n        monkeypatch.setattr(\n            asyncio, \"all_tasks\", lambda _: {t1, t2}, raising=True)\n\n        xmpp_adapter.SlixmppAdapter._finalize_loop(loop)\n        assert t1.cancelled()\n        assert t2.cancelled()\n\n    finally:\n        # Avoid asyncio.get_event_loop() here to prevent DeprecationWarning on\n        # newer Python versions.  Setting to None is sufficient for isolation.\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_disabled(monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.TLS,\n        verify_certificate=True,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1,\n    )\n    monkeypatch.setattr(a, \"_enabled\", False, raising=True)\n    assert a.process() is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_fail(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.WARNING)\n\n    def bad_connect(self: FakeClientXMPP, **kw: Any) -> asyncio.Future[bool]:\n        fut: asyncio.Future[bool] = self.loop.create_future()\n        fut.set_result(False)\n        return fut\n\n    monkeypatch.setattr(FakeClientXMPP, \"connect\", bad_connect, raising=True)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=True,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1,\n    )\n    assert a.process() is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_success(monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.TLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1.0,\n    )\n    assert a.process() is True\n\n    # Test with roster=True\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1.0,\n        roster=True\n    )\n    assert a.process() is True\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_default_target(monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1.0,\n    )\n    assert a.process() is True\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_exception(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.DEBUG)\n\n    def explode(self: FakeClientXMPP, **kw: Any) -> bool:\n        raise RuntimeError(\"boom\")\n\n    monkeypatch.setattr(FakeClientXMPP, \"connect\", explode, raising=True)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.TLS,\n        verify_certificate=True,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1,\n    )\n    assert a.process() is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_before_message(monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"Ensure before_message callback is invoked for each target.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    called: list[int] = []\n\n    def before_message() -> None:\n        called.append(1)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\", \"b@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1,\n        before_message=before_message,\n    )\n    assert a.process() is True\n    assert len(called) == 2\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_failed_auth_disconnect(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"Authentication failure path must still call disconnect().\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    # Force the connect() path to trigger the failed_auth handler.\n    def connect_failed_auth(\n        self: FakeClientXMPP, **kw: Any\n    ) -> asyncio.Future[bool]:\n        loop = self.loop or asyncio.get_running_loop()\n        if self.disconnected is None:\n            self.disconnected = loop.create_future()\n\n        fut: asyncio.Future[bool] = loop.create_future()\n        fut.set_result(True)\n\n        handler = self.handlers.get(\"failed_auth\")\n        if handler:\n            loop.call_soon(handler)\n            loop.call_soon(self.disconnect)\n\n        return fut\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"connect\", connect_failed_auth, raising=True)\n\n    disconnected = {\"called\": False}\n\n    def disconnect(self: FakeClientXMPP) -> None:\n        disconnected[\"called\"] = True\n        if not self.disconnected.done():\n            self.disconnected.set_result(True)\n\n    monkeypatch.setattr(FakeClientXMPP, \"disconnect\", disconnect, raising=True)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.TLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=1,\n    )\n    assert a.process() is False\n    assert disconnected[\"called\"] is True\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_normalize_jid() -> None:\n    \"\"\"Tests normalize_jid()\"\"\"\n    assert NotifyXMPP.normalize_jid(\"user\", \"example.ca\") == \"user@example.ca\"\n    assert NotifyXMPP.normalize_jid(\n        \"user/resource\", \"example.ca\") == \"user@example.ca/resource\"\n    assert NotifyXMPP.normalize_jid(\n        \"user/resource/extra/crap\", \"example.ca\") == \"user@example.ca/resource\"\n    assert NotifyXMPP.normalize_jid(\n        \"user@example.com/r1\", \"example.ca\") == \"user@example.com/r1\"\n\n    with pytest.raises(ValueError):\n        # Bad entry\n        NotifyXMPP.normalize_jid(\"\", \"example.ca\")\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_bridge_logging_safe_lock(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"tests bridge logging on multiple attempts\"\"\"\n    # Reset module globals\n    monkeypatch.setattr(xmpp_adapter, \"_LOG_BRIDGED\", False, raising=True)\n\n    # Acquire the lock so the worker blocks inside bridge_slixmpp_logging()\n    xmpp_adapter._LOG_BRIDGE_LOCK.acquire()\n\n    done = {\"ok\": False}\n\n    def worker() -> None:\n        try:\n            xmpp_adapter.bridge_slixmpp_logging()\n            done[\"ok\"] = True\n        finally:\n            pass\n\n    t = threading.Thread(target=worker, daemon=True)\n    t.start()\n\n    # Give worker a moment to pass the outer if and block on the lock\n    time.sleep(0.02)\n\n    # Now flip the flag while the lock is still held; when released, worker\n    # will acquire the lock and hit the *inner* early-return.\n    monkeypatch.setattr(xmpp_adapter, \"_LOG_BRIDGED\", True, raising=True)\n\n    xmpp_adapter._LOG_BRIDGE_LOCK.release()\n    t.join(timeout=1.0)\n\n    assert done[\"ok\"] is True\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_multi_logging_bridge_handling(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"Handles multiple logging attempts made by multiple xmpp instances\"\"\"\n\n    # Reset module globals\n    monkeypatch.setattr(xmpp_adapter, \"_LOG_BRIDGED\", False, raising=True)\n\n    apprise_logger = logging.getLogger(\"apprise\")\n    slix_logger = logging.getLogger(\"slixmpp\")\n\n    # Preserve existing handlers\n    old_apprise = list(apprise_logger.handlers)\n    old_slix = list(slix_logger.handlers)\n\n    try:\n        handler = logging.NullHandler()\n\n        # Arrange for slix to already have the handler\n        apprise_logger.handlers = [handler]\n        slix_logger.handlers = [handler]\n\n        xmpp_adapter.bridge_slixmpp_logging()\n\n        # Handler should not be duplicated\n        assert slix_logger.handlers.count(handler) == 1\n\n    finally:\n        apprise_logger.handlers = old_apprise\n        slix_logger.handlers = old_slix\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_failure_cleanup(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"Tests edge cases for failures that require cleanup\"\"\"\n\n    install_fake_slixmpp(monkeypatch)\n\n    # Ensure adapter is enabled\n    monkeypatch.setattr(\n        xmpp_adapter.SlixmppAdapter, \"_enabled\", True, raising=True)\n\n    # Make new_event_loop fail before shared['loop'] is assigned\n    def raise_new_event_loop() -> Any:\n        raise RuntimeError(\"poof\")\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"new_event_loop\", raise_new_event_loop, raising=True)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n    )\n\n    # Should return False\n    assert a.process() is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_timeout_cleanup_disconnect_exception_suppressed(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"Cover timeout cleanup: loop_obj != None\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    # Ensure adapter is enabled\n    monkeypatch.setattr(\n        xmpp_adapter.SlixmppAdapter, \"_enabled\", True, raising=True)\n\n    client_created = threading.Event()\n\n    # Let us know when _Client() has been constructed (super().__init__ call)\n    orig_init = FakeClientXMPP.__init__\n\n    def init_signal(\n            self: FakeClientXMPP,\n            jid: str,\n            password: str,\n            *args: Any,\n            **kwargs: Any) -> None:\n        orig_init(self, jid, password, *args, **kwargs)\n        client_created.set()\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"__init__\", init_signal, raising=True)\n\n    # Block connect so runner cannot complete before the timeout branch.\n    def connect_blocks(\n            self: FakeClientXMPP, **kw: Any) -> asyncio.Future[bool]:\n        fut: asyncio.Future[bool] = self.loop.create_future()\n        # Never resolve.\n        return fut\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"connect\", connect_blocks, raising=True)\n\n    # Make done.wait deterministic without touching real threading.Event used\n    # by Thread internals\n    _patch_threading(\n        monkeypatch,\n        done_event_cls=_make_fake_done_event_cls(client_created),\n    )\n\n    # Capture and control loop.call_soon_threadsafe behaviour\n    import asyncio as _real_asyncio\n    orig_new_event_loop = _real_asyncio.new_event_loop\n\n    calls: list[str] = []\n\n    def new_event_loop_wrapped() -> _real_asyncio.AbstractEventLoop:\n        loop = orig_new_event_loop()\n        orig_csts = loop.call_soon_threadsafe\n\n        def csts(cb: Any, *a: Any) -> Any:\n            # Identify which callback is being scheduled\n            name = getattr(cb, \"__name__\", repr(cb))\n            calls.append(name)\n\n            # Raise only for disconnect callback to hit try/except\n            if name == \"disconnect\":\n                raise RuntimeError(\"boom\")\n\n            return orig_csts(cb, *a)\n\n        monkeypatch.setattr(loop, \"call_soon_threadsafe\", csts, raising=True)\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"new_event_loop\",\n        new_event_loop_wrapped, raising=True)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.TLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n    )\n\n    assert a.process() is False\n\n    # We should have attempted both disconnect and loop.stop scheduling\n    assert \"disconnect\" in calls\n    assert \"stop\" in calls\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_timeout_cleanup_no_client_stop_exception_suppressed(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"Cover timeout cleanup: loop_obj != None\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    monkeypatch.setattr(\n        xmpp_adapter.SlixmppAdapter, \"_enabled\", True, raising=True)\n\n    loop_created = threading.Event()\n    allow_client_create = threading.Event()\n\n    # Block FakeClientXMPP.__init__ so shared['client'] never gets set before\n    # timeout\n    orig_init = FakeClientXMPP.__init__\n\n    def init_block(\n            self: FakeClientXMPP,\n            jid: str,\n            password: str,\n            *args: Any,\n            **kwargs: Any) -> None:\n        # Wait until main thread has already taken the timeout path\n        allow_client_create.wait(timeout=1.0)\n        orig_init(self, jid, password, *args, **kwargs)\n\n    monkeypatch.setattr(FakeClientXMPP, \"__init__\", init_block, raising=True)\n\n    # Deterministic done.wait: wait until loop exists, then force timeout\n    _patch_threading(\n        monkeypatch,\n        done_event_cls=_make_fake_done_event_cls(loop_created),\n    )\n\n    # Wrap new_event_loop so we can (a) signal loop exists and (b) make stop\n    # raise\n    import asyncio as _real_asyncio\n    orig_new_event_loop = _real_asyncio.new_event_loop\n\n    calls: list[str] = []\n\n    def new_event_loop_wrapped() -> _real_asyncio.AbstractEventLoop:\n        loop = orig_new_event_loop()\n        loop_created.set()\n\n        def csts(cb: Any, *a: Any) -> Any:\n            name = getattr(cb, \"__name__\", repr(cb))\n            calls.append(name)\n            if name == \"stop\":\n                raise RuntimeError(\"boom-stop\")\n            return None\n\n        monkeypatch.setattr(loop, \"call_soon_threadsafe\", csts, raising=True)\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"new_event_loop\",\n        new_event_loop_wrapped, raising=True)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.NONE,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n    )\n\n    try:\n        assert a.process() is False\n        # client_obj is None, so no disconnect scheduling attempt should occur\n        assert \"disconnect\" not in calls\n        # stop scheduling attempted and exception suppressed\n        assert \"stop\" in calls\n    finally:\n        # Unblock the runner thread so it does not linger\n        allow_client_create.set()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_timeout_cleanup_loop_none_skips_disconnect_and_stop(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    \"\"\"Cover timeout clean-up branch where shared['loop'] is still None.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    caplog.set_level(logging.WARNING, logger=\"apprise.xmpp\")\n\n    # Ensure adapter is enabled\n    monkeypatch.setattr(\n        xmpp_adapter.SlixmppAdapter, \"_enabled\", True, raising=True)\n\n    # Gate used to block runner before it can assign shared[\"loop\"].\n    gate = threading.Event()\n\n    # Patch asyncio.new_event_loop to block until we allow it, so\n    # shared[\"loop\"] remains None when the timeout clean-up executes.\n    import asyncio as _real_asyncio\n    orig_new_event_loop = _real_asyncio.new_event_loop\n\n    def new_event_loop_blocking() -> _real_asyncio.AbstractEventLoop:\n        gate.wait(timeout=1.0)\n        return orig_new_event_loop()\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"new_event_loop\",\n        new_event_loop_blocking, raising=True)\n\n    # Make done.wait deterministic without touching real threading.Event used\n    # by Thread internals.\n    _patch_threading(\n        monkeypatch,\n        done_event_cls=_make_fake_done_event_cls(),\n    )\n\n    # Track whether any loop clean-up scheduling occurs.\n    # It must NOT when loop_obj is None.\n    called = {\"disconnect\": 0, \"stop\": 0}\n\n    # Block connect so runner cannot complete before the timeout branch.\n    def connect_blocks(\n            self: FakeClientXMPP, **kw: Any) -> asyncio.Future[bool]:\n        fut: asyncio.Future[bool] = self.loop.create_future()\n        # Never resolve.\n        return fut\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"connect\", connect_blocks, raising=True)\n\n    # As an extra guard, if loop.call_soon_threadsafe were somehow reached,\n    # we'd see it via this patch. Since loop_obj is None, it must not.\n    def fake_disconnect(self: FakeClientXMPP) -> None:\n        called[\"disconnect\"] += 1\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"disconnect\", fake_disconnect, raising=True)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5223,\n        secure=xmpp_adapter.SecureXMPPMode.TLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n    )\n\n    try:\n        # done.wait is forced False immediately, and runner is blocked at\n        # new_event_loop(), so shared['loop'] is None in the timeout clean-up.\n        assert a.process() is False\n        assert \"XMPP send timed out\" in caplog.text\n\n        # No disconnect should have been scheduled because loop_obj was None.\n        assert called[\"disconnect\"] == 0\n\n    finally:\n        # Let the runner proceed and exit\n        gate.set()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_unsupported_secure_mode(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    \"\"\"Cover unsupported secure mode path (ValueError inside runner).\"\"\"\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.WARNING, logger=\"apprise.xmpp\")\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=\"invalid\",\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n    )\n\n    assert a.process() is False\n    assert \"XMPP send failed\" in caplog.text\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_connect_timeout(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    \"\"\"Cover connect timeout branch (asyncio.TimeoutError).\"\"\"\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.WARNING, logger=\"apprise.xmpp\")\n\n    # Return a Future that never resolves.\n    connect_future: dict[str, Any] = {\"fut\": None}\n\n    def connect_never(\n            self: FakeClientXMPP, **kw: Any) -> asyncio.Future[bool]:\n        fut: asyncio.Future[bool] = self.loop.create_future()\n        connect_future[\"fut\"] = fut\n        return fut\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"connect\", connect_never, raising=True)\n\n    # Force wait_for() to raise TimeoutError immediately for this connect\n    # future.\n    real_wait_for = xmpp_adapter.asyncio.wait_for\n\n    async def wait_for_patched(\n            aw: Any, timeout: Optional[float] = None) -> Any:\n        if aw is connect_future[\"fut\"]:\n            raise asyncio.TimeoutError()\n        return await real_wait_for(aw, timeout=timeout)\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"wait_for\", wait_for_patched, raising=True\n    )\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n    )\n\n    assert a.process() is False\n    assert \"XMPP connect timed out\" in caplog.text\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_process_session_timeout(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    \"\"\"Cover session timeout branch when waiting on client.disconnected.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.WARNING, logger=\"apprise.xmpp\")\n\n    # Ensure disconnect does not complete the disconnected Future.\n    def disconnect_no_complete(self: FakeClientXMPP) -> None:\n        return None\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"disconnect\", disconnect_no_complete, raising=True)\n\n    # Capture the disconnected future created on the client\n    real_init = FakeClientXMPP.__init__\n    disconnected_future: dict[str, Any] = {\"fut\": None}\n\n    def init_capture(self: FakeClientXMPP, jid: str, password: str) -> None:\n        real_init(self, jid, password)\n        disconnected_future[\"fut\"] = self.disconnected\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"__init__\", init_capture, raising=True)\n\n    # Force wait_for() to raise TimeoutError immediately for this disconnected\n    # future.\n    real_wait_for = xmpp_adapter.asyncio.wait_for\n    calls = {\"n\": 0}\n\n    def wait_for_patched(aw: Any, timeout: Optional[float] = None) -> Any:\n        calls[\"n\"] += 1\n        if calls[\"n\"] == 2:\n            # If aw is a coroutine, close it to avoid warnings\n            with contextlib.suppress(Exception):\n                if hasattr(aw, \"close\"):\n                    aw.close()\n            raise asyncio.TimeoutError()\n        return real_wait_for(aw, timeout=timeout)\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"wait_for\", wait_for_patched, raising=True\n    )\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n    )\n\n    assert a.process() is False\n    assert \"XMPP session timed out\" in caplog.text\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_slixmpp_versioning(monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Simple checks to verify versioning works correctly\n    \"\"\"\n    # Garbage in... garbage out\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"garbage\") is False\n    # This version is too old\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"1.0.0\") is False\n\n    # Good version checks\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"1.10.0\") is True\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"1.10\") is True\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"1.10.1\") is True\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"1.10.100\") is True\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"1.11.0\") is True\n\n    # This version is too old\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"1\") is False\n\n    # This version is good\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"3\") is True\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"3.2\") is True\n\n    monkeypatch.setattr(\n        xmpp_adapter, \"SLIXMPP_SUPPORT_AVAILABLE\", False, raising=False)\n    monkeypatch.setattr(\n        xmpp_adapter.SlixmppAdapter,\n        \"_enabled\",\n        False,\n        raising=False,\n    )\n    assert xmpp_adapter.SlixmppAdapter.supported_version(\"3.2\") is False\n\n\n# ---------------------------------------------------------------------------\n# Keepalive Tests\n# ---------------------------------------------------------------------------\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_keepalive_url_and_parse() -> None:\n    n = NotifyXMPP(\n        host=\"example.com\",\n        user=\"me@example.com\",\n        password=\"x\",\n        targets=[\"a@example.com\"],\n        keepalive=True,\n    )\n    u = n.url(privacy=False)\n    assert \"keepalive=yes\" in u\n\n    r = NotifyXMPP.parse_url(\n        \"xmpp://me:pass@example.com/a@example.com?keepalive=yes\"\n    )\n    assert r[\"keepalive\"] is True\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_xmpp_keepalive_reuses_adapter_instance(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    created = {\"count\": 0}\n    calls = {\"send_message\": 0, \"process\": 0}\n\n    class _Adapter:\n        def __init__(self, *args: Any, **kwargs: Any) -> None:\n            created[\"count\"] += 1\n            self.keepalive = kwargs.get(\"keepalive\", False)\n\n        def process(self) -> bool:\n            calls[\"process\"] += 1\n            return True\n\n        def send_message(self, **kwargs: Any) -> bool:\n            calls[\"send_message\"] += 1\n            return True\n\n        def close(self) -> None:\n            return None\n\n    monkeypatch.setattr(xmpp_base, \"SlixmppAdapter\", _Adapter, raising=True)\n\n    # keepalive=yes should create adapter once, then reuse it\n    n = NotifyXMPP(\n        host=\"example.com\",\n        user=\"me@example.com\",\n        password=\"x\",\n        targets=[\"a@example.com\"],\n        keepalive=True,\n    )\n    assert n.send(\"body1\", title=\"t1\") is True\n    assert n.send(\"body2\", title=\"t2\") is True\n\n    assert created[\"count\"] == 1\n    assert calls[\"send_message\"] == 2\n    assert calls[\"process\"] == 0\n\n    # keepalive=no should not reuse adapter, it uses process() one-shot\n    created[\"count\"] = 0\n    calls[\"send_message\"] = 0\n    calls[\"process\"] = 0\n\n    n = NotifyXMPP(\n        host=\"example.com\",\n        user=\"me@example.com\",\n        password=\"x\",\n        targets=[\"a@example.com\"],\n        keepalive=False,\n    )\n    assert n.send(\"body\", title=\"t\") is True\n    assert created[\"count\"] == 1\n    assert calls[\"process\"] == 1\n    assert calls[\"send_message\"] == 0\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_message_keepalive_false_calls_process(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.NONE,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n        keepalive=False,\n    )\n\n    called = {\"process\": 0}\n\n    def process() -> bool:\n        called[\"process\"] += 1\n        return True\n\n    monkeypatch.setattr(a, \"process\", process, raising=True)\n\n    assert a.send_message(targets=[\"x\"], subject=\"y\", body=\"z\") is True\n    assert called[\"process\"] == 1\n    assert a.targets == [\"x\"]\n    assert a.subject == \"y\"\n    assert a.body == \"z\"\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_ensure_keepalive_worker_edge_cases(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    # keepalive False => False\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=False,\n    )\n    assert a._ensure_keepalive_worker() is False\n\n    # keepalive True but closing => False\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n    )\n    a._closing = True\n    assert a._ensure_keepalive_worker() is False\n\n    # keepalive True but disabled => False\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n    )\n    monkeypatch.setattr(a, \"_enabled\", False, raising=True)\n    assert a._ensure_keepalive_worker() is False\n\n    # thread already alive => True\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n    )\n\n    class _AliveThread:\n        def is_alive(self) -> bool:\n            return True\n\n    a._thread = _AliveThread()\n    assert a._ensure_keepalive_worker() is True\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_ensure_keepalive_worker_wait_timeout(\n        monkeypatch: pytest.MonkeyPatch,\n        caplog: pytest.LogCaptureFixture) -> None:\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.WARNING, logger=\"apprise.xmpp\")\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        timeout=0.1,\n        keepalive=True,\n    )\n\n    # Prevent starting a real loop thread\n    monkeypatch.setattr(a, \"_keepalive_runner\", lambda: None, raising=True)\n\n    # Force wait() to fail\n    monkeypatch.setattr(\n        a._loop_ready, \"wait\", lambda timeout=None: False, raising=True)\n\n    assert a._ensure_keepalive_worker() is False\n    assert \"keepalive worker failed to start\" in caplog.text\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_early_returns(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    assert asyncio.run(a._connect_if_required()) is False\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n\n        assert run_on_loop(loop, a._connect_if_required()) is False\n        a._client = SimpleNamespace()\n        assert run_on_loop(loop, a._connect_if_required()) is False\n        a._connect_lock = asyncio.Lock()\n        assert run_on_loop(loop, a._connect_if_required()) is False\n        a._session_started = asyncio.Event()\n        assert run_on_loop(loop, a._connect_if_required()) is False\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_already_connected(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n        a._session_started.set()\n\n        called = {\"connect\": 0}\n\n        class _Client:\n            def connect(self, **kwargs: Any) -> Any:\n                called[\"connect\"] += 1\n                fut = loop.create_future()\n                fut.set_result(True)\n                return fut\n\n        a._client = _Client()\n        assert run_on_loop(loop, a._connect_if_required()) is True\n        assert called[\"connect\"] == 0\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_connect_timeout(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config, targets=[], subject=\"s\", body=\"b\", timeout=5.0,\n        keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n\n        fut = loop.create_future()\n\n        class _Client:\n            def connect(self, **kwargs: Any) -> Any:\n                return fut\n\n        a._client = _Client()\n\n        real_wait_for = xmpp_adapter.asyncio.wait_for\n\n        def wait_for_patched(aw: Any, timeout: Optional[float] = None) -> Any:\n            if aw is fut:\n                raise asyncio.TimeoutError()\n            return real_wait_for(aw, timeout=timeout)\n\n        monkeypatch.setattr(\n            xmpp_adapter.asyncio, \"wait_for\", wait_for_patched, raising=True)\n\n        assert run_on_loop(loop, a._connect_if_required()) is False\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_connect_exception(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n\n        class _Client:\n            def connect(self, **kwargs: Any) -> Any:\n                raise RuntimeError(\"boom\")\n\n        a._client = _Client()\n        assert run_on_loop(loop, a._connect_if_required()) is False\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_session_start_timeout(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config, targets=[], subject=\"s\", body=\"b\", timeout=5.0,\n        keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n\n        fut = loop.create_future()\n        fut.set_result(True)\n\n        class _Client:\n            def connect(self, **kwargs: Any) -> Any:\n                return fut\n\n        a._client = _Client()\n\n        # First wait_for succeeds (connect), second one raises\n        # (session start wait)\n        real_wait_for = xmpp_adapter.asyncio.wait_for\n        calls = {\"n\": 0}\n\n        async def wait_for_patched(\n            aw: Any, timeout: Optional[float] = None\n        ) -> Any:\n            calls[\"n\"] += 1\n            if calls[\"n\"] == 2:\n                # Prevent \"coroutine Event.wait was never awaited\"\n                with contextlib.suppress(Exception):\n                    if hasattr(aw, \"close\"):\n                        aw.close()\n\n                raise asyncio.TimeoutError()\n            return await real_wait_for(aw, timeout=timeout)\n\n        monkeypatch.setattr(\n            xmpp_adapter.asyncio, \"wait_for\", wait_for_patched, raising=True\n        )\n\n        assert run_on_loop(loop, a._connect_if_required()) is False\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_keepalive_async_default_target_and_send_error(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n        a._session_started.set()\n\n        sent: list[str] = []\n\n        class _Client:\n            def send_message(self, **kwargs: Any) -> None:\n                sent.append(kwargs.get(\"mto\"))\n\n        a._client = _Client()\n\n        async def ok_connect() -> bool:\n            return True\n\n        monkeypatch.setattr(\n            a, \"_connect_if_required\", ok_connect, raising=True)\n\n        assert run_on_loop(loop, a._send_keepalive_async(\n            targets=[], subject=\"s\", body=\"b\")) is True\n        assert sent == [\"me@example.com\"]\n\n        # Now force send_message to raise and ensure session_started clears\n        def boom_send_message(self: _Client, **kwargs: Any) -> None:\n            raise RuntimeError(\"boom-send\")\n\n        monkeypatch.setattr(\n            _Client, \"send_message\", boom_send_message, raising=True)\n\n        assert a._session_started.is_set() is True\n\n        assert run_on_loop(loop, a._send_keepalive_async(\n                targets=[], subject=\"s\", body=\"b\")) is False\n        assert a._session_started.is_set() is False\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_message_keepalive_timeout_and_exception(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    install_fake_slixmpp(monkeypatch)\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config, targets=[], subject=\"s\", body=\"b\", timeout=5.0,\n        keepalive=True\n    )\n\n    # Ensure worker exists without spinning a real thread\n    monkeypatch.setattr(\n        a, \"_ensure_keepalive_worker\", lambda: True, raising=True)\n\n    calls: list[str] = []\n\n    class _Loop:\n        def call_soon_threadsafe(self, cb: Any, *args: Any) -> None:\n            calls.append(getattr(cb, \"__name__\", \"cb\"))\n            cb(*args)\n\n    a._loop = _Loop()\n\n    cleared = {\"n\": 0}\n\n    class _Evt:\n        def clear(self) -> None:\n            cleared[\"n\"] += 1\n\n    a._session_started = _Evt()\n\n    # Timeout path\n    class _FutureTimeout:\n        def result(self, timeout: Optional[float] = None) -> Any:\n            raise xmpp_adapter.FuturesTimeoutError()\n\n    def run_coroutine_threadsafe_timeout(coro: Any, loop: Any) -> Any:\n        # Prevent \"never awaited\" warnings\n        with contextlib.suppress(Exception):\n            coro.close()\n        return _FutureTimeout()\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"run_coroutine_threadsafe\",\n        run_coroutine_threadsafe_timeout,\n        raising=True,\n    )\n\n    assert a.send_message(targets=[], subject=\"s\", body=\"b\") is False\n    assert cleared[\"n\"] == 1\n\n    # Exception path\n    class _FutureError:\n        def result(self, timeout: Optional[float] = None) -> Any:\n            raise RuntimeError(\"boom\")\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"run_coroutine_threadsafe\",\n        run_coroutine_threadsafe_timeout,\n        raising=True,\n    )\n\n    assert a.send_message(targets=[], subject=\"s\", body=\"b\") is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_del_suppresses_close_exception(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Cover SlixmppAdapter.__del__() exception suppression\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    def boom_close() -> None:\n        raise RuntimeError(\"boom-close\")\n\n    monkeypatch.setattr(a, \"close\", boom_close, raising=True)\n\n    # Ensure __del__ runs and does not raise\n    del a\n    gc.collect()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_close_shutdown_paths_and_state_reset(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Cover close(): _shutdown() branches and loop.call_soon_threadsafe exception\n    suppression, plus state reset\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    class _Client:\n        def disconnect(self) -> None:\n            raise RuntimeError(\"disconnect boom\")\n\n    class _Loop:\n        def __init__(self) -> None:\n            self.called: list[str] = []\n\n        def stop(self) -> None:\n            self.called.append(\"stop\")\n            raise RuntimeError(\"stop boom\")\n\n        def call_soon_threadsafe(self, cb: Any, *args: Any) -> None:\n            self.called.append(\"call_soon_threadsafe\")\n            # Execute immediately so _shutdown() runs in this test\n            cb(*args)\n\n    class _Thread:\n        def join(self, timeout: Optional[float] = None) -> None:\n            return None\n\n    # Populate internals so close() takes the full path\n    loop = _Loop()\n    a._loop = loop\n    a._client = _Client()\n    a._thread = _Thread()\n\n    # Also set asyncio primitives to ensure they are nulled out\n    a._connect_lock = asyncio.Lock()\n    a._session_started = asyncio.Event()\n\n    a.close()\n\n    # State must be cleared\n    assert a._thread is None\n    assert a._loop is None\n    assert a._client is None\n    assert a._connect_lock is None\n    assert a._session_started is None\n\n    # Ensure call_soon_threadsafe ran (and stop attempted inside _shutdown)\n    assert \"call_soon_threadsafe\" in loop.called\n    assert \"stop\" in loop.called\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_close_call_soon_threadsafe_raises_still_resets(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Cover close(): loop.call_soon_threadsafe(_shutdown) raising and being\n    suppressed, while still joining and clearing state.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    class _Loop:\n        def call_soon_threadsafe(self, cb: Any, *args: Any) -> None:\n            raise RuntimeError(\"csts boom\")\n\n        def stop(self) -> None:\n            return None\n\n    class _Thread:\n        def join(self, timeout: Optional[float] = None) -> None:\n            return None\n\n    a._loop = _Loop()\n    a._client = SimpleNamespace(disconnect=lambda: None)\n    a._thread = _Thread()\n    a._connect_lock = asyncio.Lock()\n    a._session_started = asyncio.Event()\n\n    a.close()\n\n    assert a._thread is None\n    assert a._loop is None\n    assert a._client is None\n    assert a._connect_lock is None\n    assert a._session_started is None\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_ensure_keepalive_worker_returns_true_at_end(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Hit _ensure_keepalive_worker() bottom return True\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", timeout=5.0,\n        keepalive=True\n    )\n\n    # Make the runner set loop_ready quickly and exit, using a real thread.\n    def runner() -> None:\n        a._loop_ready.set()\n        return None\n\n    monkeypatch.setattr(a, \"_keepalive_runner\", runner, raising=True)\n\n    assert a._ensure_keepalive_worker() is True\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_executes_and_register_plugin_suppressed(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"\n    Execute _keepalive_runner() including:\n    - secure mode config\n    - register_plugin() suppression\n    - loop.run_forever() exit\n    - finally: loop.stop/close\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    # Force register_plugin to raise so the suppress(Exception) is exercised\n    def boom_register_plugin(\n            self: FakeClientXMPP, name: str,\n            config: Optional[Any] = None) -> None:\n        raise RuntimeError(\"boom-plugin\")\n\n    monkeypatch.setattr(\n        FakeClientXMPP, \"register_plugin\", boom_register_plugin, raising=True)\n\n    # Wrap new_event_loop so we can make run_forever return immediately, and\n    # make stop/close callable.\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n\n    def new_event_loop_wrapped() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n\n        # Exit immediately instead of blocking forever\n        monkeypatch.setattr(loop, \"run_forever\", lambda: None, raising=True)\n\n        # Ensure stop/close are callable (real loop has them already)\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"new_event_loop\", new_event_loop_wrapped,\n        raising=True)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    # Run synchronously. Since run_forever() is patched to return, this\n    # should exit quickly.\n    a._keepalive_runner()\n\n    # Worker should have published these before entering run_forever()\n    assert a._loop is not None\n    assert a._client is not None\n    assert a._connect_lock is not None\n    assert a._session_started is not None\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_hits_bottom_return_true(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Cover _connect_if_required() success path reaching the final return True,\n    not the early already-connected return.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", timeout=5.0,\n        keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n\n        fut = loop.create_future()\n        fut.set_result(True)\n\n        class _Client:\n            def connect(self, **kwargs: Any) -> Any:\n                return fut\n\n        a._client = _Client()\n\n        # Patch wait_for so the second wait_for (session_started.wait()) sets\n        # the event just in time, forcing the bottom return True.\n        real_wait_for = xmpp_adapter.asyncio.wait_for\n        calls = {\"n\": 0}\n\n        async def wait_for_patched(\n                aw: Any, timeout: Optional[float] = None) -> Any:\n            calls[\"n\"] += 1\n            if calls[\"n\"] == 2:\n                assert a._session_started is not None\n                a._session_started.set()\n            return await real_wait_for(aw, timeout=timeout)\n\n        monkeypatch.setattr(\n            xmpp_adapter.asyncio, \"wait_for\", wait_for_patched, raising=True)\n\n        assert run_on_loop(loop, a._connect_if_required()) is True\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_keepalive_async_connect_fail_branch(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Cover _send_keepalive_async(): if not ok: return False\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    # Must not be None, or it returns False earlier\n    a._client = SimpleNamespace(send_message=lambda **kw: None)\n\n    async def fail_connect() -> bool:\n        return False\n\n    monkeypatch.setattr(a, \"_connect_if_required\", fail_connect, raising=True)\n\n    assert asyncio.run(\n        a._send_keepalive_async(\n            targets=[\"x\"], subject=\"s\", body=\"b\")) is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_keepalive_async_exception_when_session_started_none(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Cover _send_keepalive_async() exception path where _session_started is None\n    so clear() is skipped\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    async def ok_connect() -> bool:\n        return True\n\n    monkeypatch.setattr(a, \"_connect_if_required\", ok_connect, raising=True)\n\n    class _Client:\n        def send_message(self, **kwargs: Any) -> None:\n            raise RuntimeError(\"boom-send\")\n\n    a._client = _Client()\n\n    # Key detail: explicitly set to None to take the \"skip clear()\" branch\n    a._session_started = None\n\n    assert asyncio.run(\n        a._send_keepalive_async(\n            targets=[\"x\"], subject=\"s\", body=\"b\")) is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_close_shutdown_client_none_branch(\n        monkeypatch: pytest.MonkeyPatch) -> None:\n    \"\"\"\n    Cover close(): _shutdown() path where client is None.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    class _Loop:\n        def __init__(self) -> None:\n            self.stopped = False\n            self.called = False\n\n        def stop(self) -> None:\n            self.stopped = True\n\n        def call_soon_threadsafe(self, cb: Any, *args: Any) -> None:\n            self.called = True\n            cb(*args)\n\n    class _Thread:\n        def join(self, timeout: Optional[float] = None) -> None:\n            return None\n\n    loop = _Loop()\n    a._loop = loop\n    a._thread = _Thread()\n\n    # Key: ensure client is None so if client is not None is False\n    a._client = None\n\n    a.close()\n\n    assert loop.called is True\n    assert loop.stopped is True\n    assert a._loop is None\n    assert a._thread is None\n    assert a._client is None\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_close_thread_alive_returns_without_clearing_state(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover close() branch where worker thread is still alive.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    class _Client:\n        def disconnect(self) -> None:\n            return None\n\n    class _Loop:\n        def __init__(self) -> None:\n            self.called: list[str] = []\n\n        def stop(self) -> None:\n            self.called.append(\"stop\")\n\n        def call_soon_threadsafe(self, cb: Any, *args: Any) -> None:\n            self.called.append(\"call_soon_threadsafe\")\n            cb(*args)\n\n    class _Thread:\n        def join(self, timeout: Optional[float] = None) -> None:\n            return None\n\n        def is_alive(self) -> bool:\n            return True\n\n    loop = _Loop()\n    thread = _Thread()\n\n    a._loop = loop\n    a._client = _Client()\n    a._thread = thread\n    a._connect_lock = asyncio.Lock()\n    a._session_started = asyncio.Event()\n\n    a.close()\n\n    assert a._loop is loop\n    assert a._thread is thread\n    assert loop.called[0] == \"call_soon_threadsafe\"\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_returns_if_closing_before_publish(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _keepalive_runner() state-lock early return when closing.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    def _closing_register_plugin(\n        self: FakeClientXMPP, name: str, config: Optional[Any] = None\n    ) -> None:\n        a._closing = True\n        return None\n\n    monkeypatch.setattr(\n        FakeClientXMPP,\n        \"register_plugin\",\n        _closing_register_plugin,\n        raising=True,\n    )\n\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n\n    def _new_event_loop() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n        monkeypatch.setattr(\n            loop,\n            \"run_forever\",\n            lambda: (_ for _ in ()).throw(AssertionError(\"run_forever\")),\n            raising=True,\n        )\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"new_event_loop\",\n        _new_event_loop,\n        raising=True,\n    )\n\n    a._loop_ready.clear()\n    a._keepalive_runner()\n\n    assert a._loop_ready.is_set() is False\n    assert a._loop is None\n    assert a._client is None\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_finally_clears_state_when_closing(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _keepalive_runner() finally state-clear when closing.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n    created: list[asyncio.AbstractEventLoop] = []\n\n    def _new_event_loop() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n        created.append(loop)\n\n        def _run_forever() -> None:\n            a._closing = True\n            return None\n\n        monkeypatch.setattr(loop, \"run_forever\", _run_forever, raising=True)\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"new_event_loop\",\n        _new_event_loop,\n        raising=True,\n    )\n\n    a._loop_ready.clear()\n    a._keepalive_runner()\n\n    assert a._loop_ready.is_set() is True\n    assert a._loop is None\n    assert a._client is None\n    assert a._connect_lock is None\n    assert a._session_started is None\n\n    assert created\n    assert created[0].is_closed()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_session_start_sets_event_even_on_exception(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"Cover keepalive _Client._session_start() exception handling.\"\"\"\n\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\", password=\"x\", host=\"ex.com\", port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True,\n        roster=True, timeout=5.0,\n    )\n\n    loop = asyncio.new_event_loop()\n    # Detach from policy initially\n    asyncio.set_event_loop(None)\n\n    try:\n        def run_forever_hook() -> None:\n            client = a._client\n            started = a._session_started\n\n            monkeypatch.setattr(\n                client, \"send_presence\",\n                lambda: (_ for _ in ()).throw(RuntimeError(\"fail\")),\n                raising=True\n            )\n\n            client._on_session_start()\n\n            async def wait_for_event():\n                while not started.is_set():\n                    await asyncio.sleep(0.01)\n\n            # Assign to variable so we can explicitly close it if needed\n            coro = wait_for_event()\n            try:\n                wf = asyncio.wait_for(coro, timeout=1.0)\n                try:\n                    loop.run_until_complete(wf)\n                finally:\n                    with contextlib.suppress(Exception):\n                        wf.close()\n\n            finally:\n                # Prevent \"coroutine was never awaited\" warnings\n                with contextlib.suppress(Exception):\n                    coro.close()\n\n            assert started.is_set() is True\n            return None\n\n        monkeypatch.setattr(\n            xmpp_adapter.asyncio,\n            \"new_event_loop\",\n            lambda: loop,\n        )\n        monkeypatch.setattr(loop, \"run_forever\", run_forever_hook)\n\n        a._keepalive_runner()\n\n    finally:\n        if not loop.is_closed():\n            # Cancel all tasks, including those from internal asyncio\n            # machinery\n            for task in asyncio.all_tasks(loop):\n                task.cancel()\n\n            with contextlib.suppress(Exception):\n                loop.run_until_complete(asyncio.sleep(0))\n\n            loop.close()\n\n        # Clean up global policies\n        asyncio.set_event_loop(None)\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_session_start_roster_timeout_closes_awaitable(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover roster timeout close path in _session_start().\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    called: list[object] = []\n\n    orig_close = xmpp_adapter._close_awaitable\n\n    def _spy_close(obj: object) -> None:\n        called.append(obj)\n        orig_close(obj)\n\n    monkeypatch.setattr(\n        xmpp_adapter, \"_close_awaitable\", _spy_close, raising=True\n    )\n\n    async def _raise_timeout(*args: object, **kwargs: object) -> None:\n        raise asyncio.TimeoutError()\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"wait_for\", _raise_timeout, raising=True\n    )\n\n    loop = asyncio.new_event_loop()\n    asyncio.set_event_loop(loop)\n    try:\n        evt = asyncio.Event()\n        client = client_cls(\n            jid=\"user@example.com\",\n            password=\"pass\",\n            oneshot=False,\n            want_roster=True,\n            roster_timeout=1.0,\n            session_started_evt=evt,\n        )\n\n        loop.run_until_complete(client._session_start())\n        assert len(called) == 1\n\n    finally:\n        asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_session_start_keepalive_sets_ready_event(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover non-oneshot path in _session_start().\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    loop = asyncio.new_event_loop()\n    asyncio.set_event_loop(loop)\n    try:\n        evt = asyncio.Event()\n\n        client = client_cls(\n            jid=\"user@example.com\",\n            password=\"pass\",\n            oneshot=False,\n            targets=[\"a\", \"b\"],\n            subject=\"s\",\n            body=\"b\",\n            before_message=lambda: None,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=evt,\n        )\n\n        monkeypatch.setattr(\n            client, \"send_message\",\n            lambda **k: (_ for _ in ()).throw(AssertionError()),\n            raising=True,\n        )\n        monkeypatch.setattr(\n            client, \"disconnect\",\n            lambda: (_ for _ in ()).throw(AssertionError()),\n            raising=True,\n        )\n\n        loop.run_until_complete(client._session_start())\n        assert evt.is_set() is True\n\n    finally:\n        asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_session_start_oneshot_sends_and_disconnects(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover oneshot path in _session_start() and finally disconnect.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    sent: list[dict[str, Any]] = []\n    before: list[int] = []\n    disconnected: list[bool] = []\n\n    def _before() -> None:\n        before.append(1)\n\n    def _send_message(**kwargs: Any) -> None:\n        sent.append(kwargs)\n\n    def _disconnect() -> None:\n        disconnected.append(True)\n\n    loop = asyncio.new_event_loop()\n    asyncio.set_event_loop(loop)\n    try:\n        client = client_cls(\n            jid=\"user@example.com\",\n            password=\"pass\",\n            oneshot=True,\n            targets=[\"a\", \"b\"],\n            subject=\"sub\",\n            body=\"body\",\n            before_message=_before,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=None,\n        )\n\n        monkeypatch.setattr(\n            client, \"send_message\", _send_message, raising=True\n        )\n        monkeypatch.setattr(client, \"disconnect\", _disconnect, raising=True)\n\n        loop.run_until_complete(client._session_start())\n\n        assert len(before) == 2\n        assert len(sent) == 2\n        assert all(msg.get(\"mtype\") == \"chat\" for msg in sent)\n        assert disconnected == [True]\n\n    finally:\n        asyncio.set_event_loop(None)\n        loop.close()\n\n\ndef test_adapter_keepalive_runner_failed_handlers(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"\n    Cover keepalive _Client._failed_auth() and _disconnected().\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True,\n        roster=False\n    )\n\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n\n    def new_event_loop_wrapped() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n\n        def run_forever_hook() -> None:\n            client = a._client\n            started = a._session_started\n            assert client is not None\n            assert started is not None\n\n            disconnected = {\"called\": 0}\n\n            def disconnect_spy() -> None:\n                disconnected[\"called\"] += 1\n\n            monkeypatch.setattr(\n                client, \"disconnect\", disconnect_spy, raising=True)\n\n            # Mark started True, then failed_auth must clear + disconnect\n            started.set()\n            client._failed_auth()\n            assert started.is_set() is False\n            assert disconnected[\"called\"] == 1\n\n            # Mark started True again, disconnected must clear, no disconnect\n            # call\n            started.set()\n            client._disconnected()\n            assert started.is_set() is False\n            assert disconnected[\"called\"] == 1\n\n            return None\n\n        monkeypatch.setattr(\n            loop, \"run_forever\", run_forever_hook, raising=True)\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"new_event_loop\", new_event_loop_wrapped,\n        raising=True)\n\n    a._keepalive_runner()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_unsupported_secure_mode_hits_valueerror(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"\n    Cover keepalive runner unsupported secure mode ValueError.\n    The exception is caught internally, but the raise line is executed.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=\"definitely-not-supported\",\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    # Ensure clean initial state\n    a._loop_ready.clear()\n\n    # Patch run_forever to be safe if it ever reaches it (it should not)\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n\n    def new_event_loop_wrapped() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n        monkeypatch.setattr(loop, \"run_forever\", lambda: None, raising=True)\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio, \"new_event_loop\",\n        new_event_loop_wrapped, raising=True)\n\n    a._keepalive_runner()\n\n    # Since secure mode invalid, it should not publish loop/client state\n    assert a._loop_ready.is_set() is False\n    assert a._loop is None\n    assert a._client is None\n    assert a._connect_lock is None\n    assert a._session_started is None\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_session_start_try_finally_and_roster(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"\n    Cover _session_start() try/finally and roster handling:\n    - try/finally always sets _session_started_evt\n    - roster path is taken and roster errors are suppressed\n    - send_presence exception still leads to finally:set()\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n        roster=True,\n        timeout=5.0,\n    )\n\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n\n    def new_event_loop_wrapped() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n\n        def run_forever_hook() -> None:\n            client = a._client\n            evt = a._session_started\n            assert client is not None\n            assert evt is not None\n\n            # Case 1: roster enabled, get_roster raises but suppressed\n            def send_presence_ok() -> None:\n                return None\n\n            async def get_roster_boom() -> None:\n                raise RuntimeError(\"boom-roster\")\n\n            monkeypatch.setattr(\n                client, \"send_presence\", send_presence_ok, raising=True\n            )\n            monkeypatch.setattr(\n                client, \"get_roster\", get_roster_boom, raising=True\n            )\n\n            evt.clear()\n            client._on_session_start()\n            # Drive the loop without creating extra coroutine objects\n            for _ in range(10):\n                fut = loop.create_future()\n                loop.call_soon(fut.set_result, None)\n                loop.run_until_complete(fut)\n                if evt.is_set():\n                    break\n            xmpp_adapter.SlixmppAdapter._finalize_loop(loop)\n            assert evt.is_set() is True\n\n            # Case 2: send_presence raises, finally must still set\n            def send_presence_boom() -> None:\n                raise RuntimeError(\"boom-presence\")\n\n            monkeypatch.setattr(\n                client, \"send_presence\", send_presence_boom, raising=True\n            )\n\n            evt.clear()\n            client._on_session_start()\n            # Drive the loop without creating extra coroutine objects\n            for _ in range(10):\n                fut = loop.create_future()\n                loop.call_soon(fut.set_result, None)\n                loop.run_until_complete(fut)\n                if evt.is_set():\n                    break\n            xmpp_adapter.SlixmppAdapter._finalize_loop(loop)\n            assert evt.is_set() is True\n\n            return None\n\n        monkeypatch.setattr(\n            loop, \"run_forever\", run_forever_hook, raising=True\n        )\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"new_event_loop\",\n        new_event_loop_wrapped,\n        raising=True,\n    )\n\n    a._keepalive_runner()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_plaintext_mode_skips_ssl_context(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"\n    Cover adapter.py - ensuring enable_plaintext=True,\n    so 'if not client.enable_plaintext' is False and ssl_context is not set.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.NONE,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n        timeout=5.0,\n    )\n\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n\n    def new_event_loop_wrapped() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n\n        def run_forever_hook() -> None:\n            # At this point the client has been created and configured.\n            assert a._client is not None\n\n            # In plaintext mode we expect ssl_context not to be forced by\n            # adapter.\n            assert getattr(a._client, \"enable_plaintext\", None) is True\n            assert getattr(a._client, \"ssl_context\", None) is None\n            return None\n\n        monkeypatch.setattr(\n            loop, \"run_forever\", run_forever_hook, raising=True\n        )\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"new_event_loop\",\n        new_event_loop_wrapped,\n        raising=True,\n    )\n\n    a._keepalive_runner()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_finally_loop_none(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"\n    Cover adapter.py when loop is None (new_event_loop fails),\n    so both 'if loop is not None' conditions are False.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"new_event_loop\",\n        lambda: (_ for _ in ()).throw(RuntimeError(\"boom\")),\n        raising=True,\n    )\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    # Should not raise, finally must handle loop=None\n    a._keepalive_runner()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_keepalive_runner_finally_stop_close_exceptions_suppressed(\n    monkeypatch: pytest.MonkeyPatch\n) -> None:\n    \"\"\"\n    Cover adapter.py where loop.stop() and loop.close() raise,\n    and exceptions are swallowed.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\", password=\"x\", host=\"ex.com\", port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS, verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True,\n        roster=True, timeout=5.0,\n    )\n\n    real_new_event_loop = xmpp_adapter.asyncio.new_event_loop\n\n    def new_event_loop_wrapped() -> asyncio.AbstractEventLoop:\n        loop = real_new_event_loop()\n\n        # Force finally path with a raised exception from run_forever\n        def run_forever_boom() -> None:\n            raise RuntimeError(\"boom-forever\")\n\n        monkeypatch.setattr(\n            loop, \"run_forever\", run_forever_boom, raising=True\n        )\n\n        # Make stop/close raise to exercise both except blocks\n        monkeypatch.setattr(\n            loop,\n            \"stop\",\n            lambda: (_ for _ in ()).throw(RuntimeError(\"boom-stop\")),\n            raising=True,\n        )\n        monkeypatch.setattr(\n            loop,\n            \"close\",\n            lambda: (_ for _ in ()).throw(RuntimeError(\"boom-close\")),\n            raising=True,\n        )\n\n        return loop\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"new_event_loop\",\n        new_event_loop_wrapped,\n        raising=True,\n    )\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    # Should not raise, stop/close exceptions must be suppressed\n    a._keepalive_runner()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_keepalive_async_returns_false_when_client_none(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover adapter.py _client is None -> return False.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n    )\n\n    a._client = None\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        result = loop.run_until_complete(\n            a._send_keepalive_async([\"t\"], \"s\", \"b\")\n        )\n        assert result is False\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n\n        # Safe shutdown of asyncgens\n        if not loop.is_closed():\n            with contextlib.suppress(Exception):\n                ag_coro = loop.shutdown_asyncgens()\n                try:\n                    loop.run_until_complete(ag_coro)\n                except Exception:\n                    # Reuse the helper from the adapter module if available,\n                    # or do a manual close\n                    if hasattr(ag_coro, \"close\"):\n                        ag_coro.close()\n\n        # Ensure close() is called even if the block above had issues\n        with contextlib.suppress(Exception):\n            loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_message_keepalive_exception_close_failure_suppressed(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    install_fake_slixmpp(monkeypatch)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n        keepalive=True,\n    )\n\n    monkeypatch.setattr(\n        a, \"_ensure_keepalive_worker\", lambda: True, raising=True\n    )\n\n    class _Loop:\n        def call_soon_threadsafe(self, cb: Any, *args: Any) -> None:\n            cb(*args)\n\n    a._loop = _Loop()\n\n    class _BadCoro:\n        def close(self) -> None:\n            raise RuntimeError(\"close-failed\")\n\n    monkeypatch.setattr(\n        a, \"_send_keepalive_async\", lambda **kwargs: _BadCoro(), raising=True\n    )\n\n    def run_coroutine_threadsafe_raises(coro: Any, loop: Any) -> Any:\n        raise RuntimeError(\"schedule-failed\")\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"run_coroutine_threadsafe\",\n        run_coroutine_threadsafe_raises,\n        raising=True,\n    )\n\n    assert a.send_message(targets=[], subject=\"s\", body=\"b\") is False\n\n\ndef test_adapter_send_message_keepalive_false_none_params_does_not_override(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    install_fake_slixmpp(monkeypatch)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.NONE,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"orig@example.com\"],\n        subject=\"orig-subj\",\n        body=\"orig-body\",\n        keepalive=False,\n    )\n\n    called = {\"process\": 0}\n\n    def process() -> bool:\n        called[\"process\"] += 1\n        return True\n\n    monkeypatch.setattr(a, \"process\", process, raising=True)\n\n    assert a.send_message() is True\n    assert called[\"process\"] == 1\n    assert a.targets == [\"orig@example.com\"]\n    assert a.subject == \"orig-subj\"\n    assert a.body == \"orig-body\"\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_message_keepalive_worker_failure_returns_false(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    install_fake_slixmpp(monkeypatch)\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n    )\n\n    monkeypatch.setattr(\n        a, \"_ensure_keepalive_worker\", lambda: False, raising=True\n    )\n\n    assert a.send_message() is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_message_keepalive_timeout_with_no_session_started_event(\n    monkeypatch: pytest.MonkeyPatch,\n    caplog: pytest.LogCaptureFixture,\n) -> None:\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.WARNING, logger=\"apprise.xmpp\")\n\n    config = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n\n    a = xmpp_adapter.SlixmppAdapter(\n        config=config,\n        targets=[\"a@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n        keepalive=True,\n    )\n\n    monkeypatch.setattr(\n        a, \"_ensure_keepalive_worker\", lambda: True, raising=True\n    )\n\n    class _Loop:\n        def call_soon_threadsafe(self, cb: Any, *args: Any) -> None:\n            raise AssertionError(\n                \"should not be called when _session_started is None\"\n            )\n\n    a._loop = _Loop()\n    a._session_started = None  # key for branch coverage\n\n    class _FutureTimeout:\n        def result(self, timeout: Optional[float] = None) -> Any:\n            raise xmpp_adapter.FuturesTimeoutError()\n\n    def run_coroutine_threadsafe_timeout(coro: Any, loop: Any) -> Any:\n        # Close coroutine to avoid RuntimeWarning\n        with contextlib.suppress(Exception):\n            coro.close()\n        return _FutureTimeout()\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"run_coroutine_threadsafe\",\n        run_coroutine_threadsafe_timeout,\n        raising=True,\n    )\n\n    assert a.send_message() is False\n    assert \"XMPP keepalive send timed out\" in caplog.text\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_session_start_keepalive_evt_none_no_set(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _session_start() when session event is None.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        client = client_cls(\n            jid=\"user@example.com\",\n            password=\"pass\",\n            oneshot=False,\n            targets=[],\n            subject=\"s\",\n            body=\"b\",\n            before_message=None,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=None,\n        )\n\n        loop.run_until_complete(client._session_start())\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_general_function(\n    monkeypatch: pytest.MonkeyPatch,\n    caplog: pytest.LogCaptureFixture,\n) -> None:\n    install_fake_slixmpp(monkeypatch)\n\n    def _close():\n        raise Exception(\"boom\")\n    arg = {\n        \"close\": _close\n    }\n\n    # does nothing\n    xmpp_adapter._close_awaitable(None)\n    xmpp_adapter._close_awaitable(arg)\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_on_session_start_create_task_failure_closes_coro(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover loop.create_task() failure that closes the awaitable.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n\n        client = client_cls(\n            jid=\"user@example.com\",\n            password=\"pass\",\n            oneshot=False,\n            targets=[],\n            subject=\"s\",\n            body=\"b\",\n            before_message=None,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=asyncio.Event(),\n        )\n\n        class _FakeCoro:\n            def __init__(self) -> None:\n                self.closed = False\n\n            def close(self) -> None:\n                self.closed = True\n\n        fake_coro = _FakeCoro()\n\n        def _session_start(*args: object, **kwargs: object) -> _FakeCoro:\n            return fake_coro\n\n        monkeypatch.setattr(\n            client, \"_session_start\", _session_start, raising=True\n        )\n\n        class _FakeLoop:\n            def is_running(self) -> bool:\n                return True\n\n            def create_task(self, coro: object) -> object:\n                raise RuntimeError(\"boom\")\n\n        client.loop = _FakeLoop()  # type: ignore[assignment]\n        client._on_session_start()\n        assert fake_coro.closed is True\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_disconnected_evt_none_no_clear(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _disconnected() branch where session event is None.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        client = client_cls(\n            jid=\"user@example.com\",\n            password=\"pass\",\n            oneshot=False,\n            targets=[],\n            subject=\"s\",\n            body=\"b\",\n            before_message=None,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=None,\n        )\n\n        client._disconnected()\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_message_keepalive_loop_none_returns_false(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover send_message(): loop is None -> return False.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg,\n        targets=[\"t@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n    )\n\n    monkeypatch.setattr(\n        a, \"_ensure_keepalive_worker\", lambda: True, raising=True\n    )\n    a._loop = None\n    assert a.send_message() is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_message_keepalive_exception_returns_false(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover send_message(): exception path returns False.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg,\n        targets=[\"t@example.com\"],\n        subject=\"s\",\n        body=\"b\",\n        timeout=5.0,\n        keepalive=True,\n    )\n\n    monkeypatch.setattr(\n        a, \"_ensure_keepalive_worker\", lambda: True, raising=True\n    )\n\n    class _Loop:\n        pass\n\n    a._loop = _Loop()\n\n    def run_coroutine_threadsafe_boom(coro: Any, loop: Any) -> Any:\n        with contextlib.suppress(Exception):\n            coro.close()\n        raise RuntimeError(\"boom\")\n\n    monkeypatch.setattr(\n        xmpp_adapter.asyncio,\n        \"run_coroutine_threadsafe\",\n        run_coroutine_threadsafe_boom,\n        raising=True,\n    )\n\n    assert a.send_message() is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_send_keepalive_async_auth_failed_returns_false(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _send_keepalive_async(): auth_failed -> return False.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg,\n        targets=[],\n        subject=\"s\",\n        body=\"b\",\n        keepalive=True,\n    )\n\n    class _Client:\n        _auth_failed = True\n\n        def send_message(self, **kwargs: Any) -> None:\n            raise AssertionError(\"send must not occur\")\n\n    a._client = _Client()\n\n    async def ok_connect() -> bool:\n        return True\n\n    monkeypatch.setattr(a, \"_connect_if_required\", ok_connect, raising=True)\n\n    assert asyncio.run(\n        a._send_keepalive_async([\"t@example.com\"], \"s\", \"b\")\n    ) is False\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_auth_failed_returns_false(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _connect_if_required(): auth_failed -> return False.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n\n        class _Client:\n            _auth_failed = True\n\n        a._client = _Client()\n\n        assert run_on_loop(loop, a._connect_if_required()) is False\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_connect_ok_false_disconnects(\n    monkeypatch: pytest.MonkeyPatch,\n    caplog: pytest.LogCaptureFixture,\n) -> None:\n    \"\"\"Cover _connect_if_required(): connect_ok False path.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n    caplog.set_level(logging.WARNING, logger=\"apprise.xmpp\")\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n\n        disconnected = {\"n\": 0}\n\n        class _Client:\n            def connect(self, **kwargs: Any) -> Any:\n                fut = loop.create_future()\n                fut.set_result(False)\n                return fut\n\n            def disconnect(self) -> None:\n                disconnected[\"n\"] += 1\n\n        a._client = _Client()\n\n        assert run_on_loop(loop, a._connect_if_required()) is False\n        assert disconnected[\"n\"] == 1\n        assert \"XMPP connect failed\" in caplog.text\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_adapter_connect_if_required_session_wait_exception_closes(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _connect_if_required(): session_wait Exception path.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    cfg = xmpp_adapter.XMPPConfig(\n        jid=\"me@example.com\",\n        password=\"x\",\n        host=\"example.com\",\n        port=5222,\n        secure=xmpp_adapter.SecureXMPPMode.STARTTLS,\n        verify_certificate=False,\n    )\n    a = xmpp_adapter.SlixmppAdapter(\n        config=cfg, targets=[], subject=\"s\", body=\"b\", keepalive=True\n    )\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n        a._loop = loop\n        a._connect_lock = asyncio.Lock()\n        a._session_started = asyncio.Event()\n\n        fut = loop.create_future()\n        fut.set_result(True)\n\n        class _Client:\n            def connect(self, **kwargs: Any) -> Any:\n                return fut\n\n        a._client = _Client()\n\n        called: list[object] = []\n        real_close = xmpp_adapter._close_awaitable\n\n        def close_spy(obj: object) -> None:\n            called.append(obj)\n            real_close(obj)\n\n        monkeypatch.setattr(\n            xmpp_adapter, \"_close_awaitable\", close_spy, raising=True\n        )\n\n        real_wait_for = xmpp_adapter.asyncio.wait_for\n        calls = {\"n\": 0}\n\n        async def wait_for_patched(\n            aw: Any, timeout: Optional[float] = None\n        ) -> Any:\n            calls[\"n\"] += 1\n            if calls[\"n\"] == 2:\n                raise RuntimeError(\"boom\")\n            return await real_wait_for(aw, timeout=timeout)\n\n        monkeypatch.setattr(\n            xmpp_adapter.asyncio, \"wait_for\", wait_for_patched, raising=True\n        )\n\n        assert run_on_loop(loop, a._connect_if_required()) is False\n        assert len(called) == 1\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_on_session_start_add_done_callback_executes(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"\n    Cover adapter.py _on_session_start() keepalive path where we create a\n    task and attach a done callback that calls t.exception() when not\n    cancelled.\n\n    This test avoids patching asyncio.Task (immutable on newer Pythons) by\n    using a fake loop and fake task object.\n    \"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n\n        evt = asyncio.Event()\n\n        client = client_cls(\n            jid=\"me@example.com\",\n            password=\"x\",\n            oneshot=False,\n            targets=[],\n            subject=\"s\",\n            body=\"b\",\n            before_message=None,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=evt,\n        )\n\n        class _FakeTask:\n            def __init__(self) -> None:\n                self.exception_called = False\n                self.callback_called = False\n\n            def add_done_callback(self, cb: Any) -> None:\n                self.callback_called = True\n                cb(self)\n\n            def cancelled(self) -> bool:\n                return False\n\n            def exception(self) -> None:\n                self.exception_called = True\n                return None\n\n        class _FakeLoop:\n            def __init__(self) -> None:\n                self.task = _FakeTask()\n                self.create_task_called = False\n\n            def is_running(self) -> bool:\n                return True\n\n            def create_task(self, coro: Any) -> _FakeTask:\n                self.create_task_called = True\n                return self.task\n\n        fake_loop = _FakeLoop()\n        client.loop = fake_loop  # type: ignore[assignment]\n\n        class _FakeCoro:\n            def close(self) -> None:\n                return None\n\n        def _session_start(*args: object, **kwargs: object) -> _FakeCoro:\n            return _FakeCoro()\n\n        monkeypatch.setattr(\n            client, \"_session_start\", _session_start, raising=True\n        )\n\n        client._on_session_start()\n\n        assert fake_loop.create_task_called is True\n        assert fake_loop.task.callback_called is True\n        assert fake_loop.task.exception_called is True\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        with contextlib.suppress(Exception):\n            loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_on_session_start_done_callback_cancelled_returns(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _log_task() early return when task is cancelled.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n\n        evt = asyncio.Event()\n        client = client_cls(\n            jid=\"me@example.com\",\n            password=\"x\",\n            oneshot=False,\n            targets=[],\n            subject=\"s\",\n            body=\"b\",\n            before_message=None,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=evt,\n        )\n\n        errors: list[str] = []\n\n        class _Logger:\n            def error(self, msg: str, *args: Any) -> None:\n                errors.append(msg % args if args else msg)\n\n        client.logger = _Logger()  # type: ignore[assignment]\n\n        class _FakeTask:\n            def __init__(self) -> None:\n                self.exception_called = False\n\n            def add_done_callback(self, cb: Any) -> None:\n                cb(self)\n\n            def cancelled(self) -> bool:\n                return True\n\n            def exception(self) -> None:\n                self.exception_called = True\n                return RuntimeError(\"should-not-be-read\")\n\n        class _FakeLoop:\n            def is_running(self) -> bool:\n                return True\n\n            def create_task(self, coro: Any) -> _FakeTask:\n                return _FakeTask()\n\n        client.loop = _FakeLoop()  # type: ignore[assignment]\n\n        class _FakeCoro:\n            def close(self) -> None:\n                return None\n\n        monkeypatch.setattr(\n            client, \"_session_start\", lambda *_a, **_k: _FakeCoro(),\n            raising=True\n        )\n\n        client._on_session_start()\n\n        assert errors == []\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        with contextlib.suppress(Exception):\n            loop.close()\n\n\n@pytest.mark.skipif(not SLIXMPP_AVAILABLE, reason=\"Requires slixmpp\")\ndef test_client_on_session_start_done_callback_logs_exception(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    \"\"\"Cover _log_task() error log when task.exception() is not None.\"\"\"\n    install_fake_slixmpp(monkeypatch)\n\n    client_cls = xmpp_adapter._get_client_subclass(FakeClientXMPP)\n\n    loop = asyncio.new_event_loop()\n    try:\n        asyncio.set_event_loop(loop)\n\n        evt = asyncio.Event()\n        client = client_cls(\n            jid=\"me@example.com\",\n            password=\"x\",\n            oneshot=False,\n            targets=[],\n            subject=\"s\",\n            body=\"b\",\n            before_message=None,\n            want_roster=False,\n            roster_timeout=0.0,\n            session_started_evt=evt,\n        )\n\n        errors: list[str] = []\n\n        class _Logger:\n            def error(self, msg: str, *args: Any) -> None:\n                errors.append(msg % args if args else msg)\n\n        client.logger = _Logger()  # type: ignore[assignment]\n\n        class _FakeTask:\n            def add_done_callback(self, cb: Any) -> None:\n                cb(self)\n\n            def cancelled(self) -> bool:\n                return False\n\n            def exception(self) -> Exception:\n                return RuntimeError(\"boom-task\")\n\n        class _FakeLoop:\n            def is_running(self) -> bool:\n                return True\n\n            def create_task(self, coro: Any) -> _FakeTask:\n                return _FakeTask()\n\n        client.loop = _FakeLoop()  # type: ignore[assignment]\n\n        class _FakeCoro:\n            def close(self) -> None:\n                return None\n\n        monkeypatch.setattr(\n            client, \"_session_start\", lambda *_a, **_k: _FakeCoro(),\n            raising=True\n        )\n\n        client._on_session_start()\n\n        assert len(errors) == 1\n        assert \"XMPP task failed\" in errors[0]\n\n    finally:\n        with contextlib.suppress(Exception):\n            asyncio.set_event_loop(None)\n        with contextlib.suppress(Exception):\n            loop.close()\n"
  },
  {
    "path": "tests/test_plugin_zulip.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n# Disable logging for a cleaner testing output\nimport logging\n\nfrom helpers import AppriseURLTester\nimport pytest\nimport requests\n\nfrom apprise.plugins.zulip import NotifyZulip\n\nlogging.disable(logging.CRITICAL)\n\n# Our Testing URLs\napprise_url_tests = (\n    (\n        \"zulip://\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"zulip://:@/\",\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"zulip://apprise\",\n        {\n            # Just org provided (no token or botname)\n            \"instance\": TypeError,\n        },\n    ),\n    (\n        \"zulip://botname@apprise\",\n        {\n            # Just org and botname provided (no token)\n            \"instance\": TypeError,\n        },\n    ),\n    # invalid token\n    (\n        \"zulip://botname@apprise/{}\".format(\"a\" * 24),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # invalid botname\n    (\n        \"zulip://....@apprise/{}\".format(\"a\" * 32),\n        {\n            \"instance\": TypeError,\n        },\n    ),\n    # Valid everything - botname with a dash\n    (\n        \"zulip://bot-name@apprise/{}\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n            \"privacy_url\": \"zulip://bot-name@apprise/a...a/\",\n        },\n    ),\n    # Valid everything - no target so default is used\n    (\n        \"zulip://botname@apprise/{}\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n            # Our expected url(privacy=True) startswith() response:\n            \"privacy_url\": \"zulip://botname@apprise/a...a/\",\n        },\n    ),\n    # Valid everything - organization as hostname\n    (\n        \"zulip://botname@apprise.zulipchat.com/{}\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n        },\n    ),\n    # Valid everything - 2 streams specified\n    (\n        \"zulip://botname@apprise/{}/channel1/channel2\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n        },\n    ),\n    # Valid everything - 2 streams specified (using to=)\n    (\n        \"zulip://botname@apprise/{}/?to=channel1/channel2\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n        },\n    ),\n    # Test token=\n    (\n        \"zulip://botname@apprise/?token={}&to=channel1\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n        },\n    ),\n    # Valid everything - 2 emails specified\n    (\n        \"zulip://botname@apprise/{}/user@example.com/user2@example.com\".format(\n            \"a\" * 32\n        ),\n        {\n            \"instance\": NotifyZulip,\n        },\n    ),\n    (\n        \"zulip://botname@apprise/{}\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n            # don't include an image by default\n            \"include_image\": False,\n        },\n    ),\n    (\n        \"zulip://botname@apprise/{}\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n            # force a failure\n            \"response\": False,\n            \"requests_response_code\": requests.codes.internal_server_error,\n        },\n    ),\n    (\n        \"zulip://botname@apprise/{}\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n            # throw a bizarre code forcing us to fail to look it up\n            \"response\": False,\n            \"requests_response_code\": 999,\n        },\n    ),\n    (\n        \"zulip://botname@apprise/{}\".format(\"a\" * 32),\n        {\n            \"instance\": NotifyZulip,\n            # Throws a series of i/o exceptions with this flag\n            # is set and tests that we gracefully handle them\n            \"test_requests_exceptions\": True,\n        },\n    ),\n)\n\n\ndef test_plugin_zulip_urls():\n    \"\"\"NotifyZulip() Apprise URLs.\"\"\"\n\n    # Run our general tests\n    AppriseURLTester(tests=apprise_url_tests).run_all()\n\n\ndef test_plugin_zulip_edge_cases():\n    \"\"\"NotifyZulip() Edge Cases.\"\"\"\n\n    # must be 32 characters long\n    token = \"a\" * 32\n\n    # Invalid organization\n    with pytest.raises(TypeError):\n        NotifyZulip(botname=\"test\", organization=\"#\", token=token)\n"
  },
  {
    "path": "tests/test_utils_format.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\nfrom apprise import NotifyFormat\nfrom apprise.utils.format import html_adjust, markdown_adjust, smart_split\n\n\ndef test_smart_split_prefers_newlines_over_spaces_and_punctuation():\n    \"\"\"\n    Newlines should win even if there are spaces and punctuation before the\n    limit.\n    \"\"\"\n    text = \"line1\\nline2 line3. line4\"\n    # Long enough to include the newline and some of the next line\n    limit = 12\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT)\n\n    # First chunk should end immediately after the newline\n    assert chunks[0] == \"line1\\n\"\n    # Nothing lost\n    assert \"\".join(chunks) == text\n\n\ndef test_smart_split_prefers_spaces_over_hard_split():\n    \"\"\"\n    When there are no newlines, split on the last space/tab before falling back\n    to a hard character-limit split.\n    \"\"\"\n    text = \"word1 word2 word3\"\n    # Force a split between word2 and word3\n    limit = 12  # \"word1 word2 \" is 12 characters\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT)\n\n    assert chunks == [\"word1 word2 \", \"word3\"]\n    assert \"\".join(chunks) == text\n\n\ndef test_smart_split_can_split_after_punctuation_plus_whitespace():\n    \"\"\"\n    Exercise the punctuation+whitespace pattern. In practice this collapses\n    to the same split point as the last space, but we verify the behaviour.\n    \"\"\"\n    text = \"Hello world. Again\"\n    # Force the split around \". \"\n\n    # \"Hello world. \" is 13 characters\n    limit = 13\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT)\n\n    # First chunk should end at the space after the period\n    assert chunks[0] == \"Hello world. \"\n    assert chunks[1] == \"Again\"\n    assert \"\".join(chunks) == text\n\n\ndef test_smart_split_avoids_splitting_inside_html_entity() -> None:\n    \"\"\"\n    In HTML mode we must not end a chunk in the middle of '&...;'.\n\n    We do NOT assert exact chunk values. Instead we assert:\n    - TEXT mode can split inside the entity.\n    - HTML mode never has a chunk that contains '&' without a matching ';'\n      after it in the same chunk.\n    \"\"\"\n    text = \"1234&nbsp;5678\"\n    limit = 8  # without adjustment, we would cut inside '&nbsp;'\n\n    # Plain text mode: allowed to split anywhere\n    chunks_text = smart_split(text, limit, body_format=NotifyFormat.TEXT)\n    assert \"\".join(chunks_text) == text\n\n    # Sanity: in TEXT mode we *do* split inside the entity\n    assert any(\n        \"&\" in chunk and \";\" not in chunk[chunk.find(\"&\") :]\n        for chunk in chunks_text\n    )\n\n    # HTML mode: entity-aware\n    chunks_html = smart_split(text, limit, body_format=NotifyFormat.HTML)\n    assert \"\".join(chunks_html) == text\n\n    # If a chunk contains '&', it must also contain the terminating ';'\n    # for that entity within the same chunk.\n    for chunk in chunks_html:\n        idx = chunk.find(\"&\")\n        if idx == -1:\n            continue\n        semi = chunk.find(\";\", idx + 1)\n        assert semi != -1, f\"Chunk ends inside HTML entity: {chunk!r}\"\n\n\ndef test_smart_split_avoids_splitting_inside_markdown_link() -> None:\n    \"\"\"\n    In MARKDOWN mode, do not split inside [text](url).\n\n    We only require that the full [link](...) lies in a single chunk and that\n    any '[' appearing in a chunk has a matching ')' in that same chunk.\n    \"\"\"\n    link = \"[link](https://example.com)\"\n    text = \"AAAA\" + link\n    limit = len(link)\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN)\n    assert \"\".join(chunks) == text\n\n    # Entire link must be contained in one chunk\n    assert any(link in chunk for chunk in chunks)\n\n    # If a chunk has '[', it must also contain its closing ')'\n    for chunk in chunks:\n        idx = chunk.find(\"[\")\n        if idx == -1:\n            continue\n        semi = chunk.find(\")\", idx + 1)\n        assert semi != -1, f\"Markdown link was split inside chunk: {chunk!r}\"\n\n\ndef test_smart_split_avoids_splitting_inside_markdown_image() -> None:\n    \"\"\"\n    In MARKDOWN mode, do not split inside the [alt](url) of an image.\n\n    The implementation currently splits \"AAAA![alt](...)\" as:\n      - \"AAAA!\"\n      - \"[alt](...)\"\n    which is acceptable, as the [alt](url) part is kept intact.\n    \"\"\"\n    image = \"![alt](https://example.com/image.png)\"\n    text = \"AAAA\" + image\n    limit = len(image)\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN)\n    assert \"\".join(chunks) == text\n\n    inner = \"[alt](https://example.com/image.png)\"\n\n    # The [alt](...) portion must appear fully within a single chunk\n    assert any(inner in chunk for chunk in chunks)\n\n    # As with links, any '[' in a chunk must have its matching ')' within\n    # the same chunk so we never split inside the [alt](url) part.\n    for chunk in chunks:\n        idx = chunk.find(\"[\")\n        if idx == -1:\n            continue\n        semi = chunk.find(\")\", idx + 1)\n        assert semi != -1, f\"Markdown image was split inside chunk: {chunk!r}\"\n\n\ndef test_smart_split_empty_and_none_input() -> None:\n    \"\"\"\n    Empty / None input should be returned as a single-element list unchanged.\n    \"\"\"\n    assert smart_split(\"\", 10, body_format=NotifyFormat.TEXT) == [\"\"]\n    assert smart_split(\"\", 0, body_format=NotifyFormat.TEXT) == [\"\"]\n    assert smart_split(\"content\", 0, body_format=NotifyFormat.TEXT) == [\"\"]\n    # None short-circuits before len() is called\n    assert smart_split(None, 10, body_format=NotifyFormat.TEXT) == [\"\"]\n\n\ndef test_smart_split_html_entity_exact_boundary() -> None:\n    \"\"\"\n    Splitting exactly at an HTML entity boundary should not shift the split\n    point (no need to \"fix up\" a perfectly aligned boundary).\n    \"\"\"\n    text = \"AAAA&nbsp;BBBB\"\n    limit = len(\"AAAA&nbsp;\")  # split exactly after the entity\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.HTML)\n\n    # We expect the entity to remain whole in the first chunk\n    assert chunks == [\"AAAA&nbsp;\", \"BBBB\"]\n    assert \"\".join(chunks) == text\n\n\ndef test_smart_split_markdown_link_exact_boundary() -> None:\n    \"\"\"\n    Splitting exactly after a Markdown link should not cause any adjustment.\n    \"\"\"\n    link = \"[link](https://example.com)\"\n    tail = \" TAIL\"\n    text = link + tail\n    limit = len(link)  # split immediately after ')'\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN)\n\n    # First chunk is exactly the link, second is the remainder\n    assert chunks[0] == link\n    assert \"\".join(chunks) == text\n\n    # Sanity: the link itself is not split across chunks\n    assert any(link in chunk for chunk in chunks)\n\n\ndef test_smart_split_whitespace_priority_with_tabs_and_newlines() -> None:\n    \"\"\"\n    Exercise newline vs space/tab priority with a mix of whitespace.\n    \"\"\"\n    text = \"word1\\tword2\\nword3\"\n\n    # Case 1: window ends just before the newline, so only tab is visible.\n    limit_without_newline = text.index(\"\\n\")  # position of '\\n'\n    chunks_no_nl = smart_split(\n        text, limit_without_newline, body_format=NotifyFormat.TEXT\n    )\n    # First chunk should end after the tab, since that is the last space/tab\n    assert chunks_no_nl[0] == \"word1\\t\"\n    assert \"\".join(chunks_no_nl) == text\n\n    # Case 2: window includes the newline; newline should win over tab.\n    limit_with_newline = text.index(\"\\n\") + 1\n    chunks_with_nl = smart_split(\n        text, limit_with_newline, body_format=NotifyFormat.TEXT\n    )\n    # First chunk should now end after the newline\n    assert chunks_with_nl[0] == \"word1\\tword2\\n\"\n    assert \"\".join(chunks_with_nl) == text\n\n\ndef test_smart_split_very_short_limit() -> None:\n    \"\"\"\n    Very small limits should still split deterministically without loss.\n    \"\"\"\n    text = \"ABC\"\n    chunks = smart_split(text, 1, body_format=NotifyFormat.TEXT)\n\n    # One character per chunk\n    assert chunks == [\"A\", \"B\", \"C\"]\n    assert \"\".join(chunks) == text\n\n\ndef test_smart_split_very_long_limit() -> None:\n    \"\"\"\n    Very large limits (>= len(text)) should return a single chunk.\n    \"\"\"\n    text = \"A short message for testing\"\n    chunks = smart_split(text, 10_000, body_format=NotifyFormat.TEXT)\n\n    assert chunks == [text]\n    assert \"\".join(chunks) == text\n\n\ndef test_html_adjust_guard_paths_and_no_entity() -> None:\n    \"\"\"\n    Cover the early-return guard in html_adjust and the path where there is\n    no '&' at all in the search window.\n    \"\"\"\n    text = \"abcdef\"\n\n    # split_at <= window_start -> early-return unchanged\n    assert html_adjust(text, window_start=2, split_at=2) == 2\n\n    # split_at beyond the end of the text -> early-return unchanged\n    assert html_adjust(\n        text, window_start=0, split_at=len(text) + 5) == len(text) + 5\n\n    # No '&' in window, nothing to adjust\n    assert html_adjust(text, window_start=0, split_at=3) == 3\n\n\ndef test_html_adjust_inside_and_at_boundary_of_entity() -> None:\n    \"\"\"\n    Exercise the path where html_adjust moves the split back to '&' when the\n    split falls inside an entity, and the path where the split is exactly at\n    the entity boundary and should not move.\n    \"\"\"\n    text = \"1234&nbsp;5678\"\n    # indexes: 0..3 '1234', 4 '&', 5 'n', 6 'b', 7 's', 8 'p', 9 ';', 10 '5'...\n\n    # Split inside '&nbsp;' (at index 8) -> move back to '&' (index 4)\n    assert html_adjust(text, window_start=0, split_at=8) == 4\n\n    # Split exactly after the ';' (index 10) -> already outside entity\n    assert html_adjust(text, window_start=0, split_at=10) == 10\n\n\ndef test_markdown_adjust_guard_and_no_construct() -> None:\n    \"\"\"\n    Cover the guard in markdown_adjust and the case where there is no\n    '[' or '!' in the window.\n    \"\"\"\n    text = \"plain text\"\n\n    # split_at <= window_start -> early-return unchanged\n    assert markdown_adjust(text, window_start=4, split_at=4) == 4\n\n    # split_at past the end -> early-return unchanged\n    assert markdown_adjust(\n        text, window_start=0, split_at=len(text) + 3) == len(text) + 3\n\n    # No markdown constructs -> nothing to adjust\n    assert markdown_adjust(text, window_start=0, split_at=5) == 5\n\n\ndef test_markdown_adjust_inside_construct_moves_to_start() -> None:\n    \"\"\"\n    Exercise the positive path in markdown_adjust where the split lands\n    inside a [text](url) construct and the function moves the split\n    back to the start of the construct.\n    \"\"\"\n    link = \"[link](https://example.com)\"\n    # Choose a split point inside the URL\n    split_at = link.index(\"(\") + 3  # somewhere inside \"(https...\"\n    adjusted = markdown_adjust(link, window_start=0, split_at=split_at)\n\n    # Should move back to the '[' at index 0\n    assert adjusted == 0\n\n\ndef test_smart_split_markdown_guard_split_at_start_is_reset() -> None:\n    \"\"\"\n    Cover the smart_split guard 'if split_at <= start: split_at = orig_split'.\n\n    We force markdown_adjust to move the split back to the window start,\n    then verify smart_split resets to the original split so progress is\n    still made and chunks join back to the original text.\n    \"\"\"\n    text = \"[link](https://example.com)\"\n    limit = 5  # will cause the first soft split to land inside the link\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.MARKDOWN)\n\n    # We should never get stuck; all chunks must be non-empty\n    assert len(chunks) >= 2\n    assert all(chunks)\n\n    # Re-joining all chunks must restore the original text\n    assert \"\".join(chunks) == text\n\n\ndef test_smart_split_uses_punctuation_branch_on_rare_whitespace() -> None:\n    \"\"\"\n    When punctuation is followed by rare whitespace (vertical tab / form feed)\n    and there are no spaces/tabs/newlines, we should use the punctuation\n    + whitespace split branch.\n    \"\"\"\n    vt = \"\\x0b\"  # vertical tab\n    text = f\"Hello.{vt}World\"\n    # Window includes 'Hello.' and the VT\n    limit = len(\"Hello.\") + 1\n\n    chunks = smart_split(text, limit, body_format=NotifyFormat.TEXT)\n\n    assert \"\".join(chunks) == text\n    # We expect the first chunk to end after the rare whitespace\n    assert chunks[0] == f\"Hello.{vt}\"\n    assert chunks[1] == \"World\"\n"
  },
  {
    "path": "tests/test_utils_pem.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport os\nimport sys\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise import AppriseAsset, PersistentStoreMode, utils\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n# Attachment Directory\nTEST_VAR_DIR = os.path.join(os.path.dirname(__file__), \"var\")\n\n\n@pytest.mark.skipif(\n    \"cryptography\" not in sys.modules, reason=\"Requires cryptography\"\n)\ndef test_utils_pem_general(tmpdir):\n    \"\"\"Utils:PEM.\"\"\"\n\n    # string to manipulate/work with\n    unencrypted_str = \"message\"\n\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n\n    # Currently no files here\n    assert os.listdir(str(tmpdir0)) == []\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.MEMORY,\n        storage_path=str(tmpdir0),\n        pem_autogen=False,\n    )\n\n    # Create a PEM Controller\n    pem_c = utils.pem.ApprisePEMController(path=None, asset=asset)\n\n    # Nothing to lookup\n    assert pem_c.public_keyfile() is None\n    assert pem_c.public_key() is None\n    assert pem_c.x962_str == \"\"\n    assert pem_c.decrypt(b\"data\") is None\n    assert pem_c.encrypt(unencrypted_str) is None\n    # Keys can not be generated in memory mode\n    assert pem_c.keygen() is False\n    assert pem_c.sign(b\"data\") is None\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir0),\n        pem_autogen=False,\n    )\n\n    # No new files\n    assert os.listdir(str(tmpdir0)) == []\n\n    # Our asset is now write mode, so we will be able to generate a key\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    # Nothing to lookup\n    assert pem_c.public_keyfile() is None\n    assert pem_c.public_key() is None\n    assert pem_c.x962_str == \"\"\n    assert pem_c.encrypt(unencrypted_str) is None\n\n    # Generate our keys\n    assert bool(pem_c) is False\n    assert pem_c.keygen() is True\n    assert bool(pem_c) is True\n\n    # We have 2 new key files generated\n    pub_keyfile = os.path.join(str(tmpdir0), \"public_key.pem\")\n    prv_keyfile = os.path.join(str(tmpdir0), \"private_key.pem\")\n    assert os.path.isfile(pub_keyfile)\n    assert os.path.isfile(prv_keyfile)\n    assert pem_c.public_keyfile() is not None\n    assert pem_c.decrypt(\"garbage\") is None\n    assert pem_c.public_key() is not None\n\n    # Keys used later on as ref\n    pubkey_ref = pem_c.public_key()\n    prvkey_ref = pem_c.private_key()\n\n    assert isinstance(pem_c.x962_str, str)\n    assert len(pem_c.x962_str) > 20\n    content = pem_c.encrypt(unencrypted_str)\n    assert pem_c.decrypt(\n        pem_c.encrypt(unencrypted_str.encode(\"utf-8\"))\n    ) == pem_c.decrypt(pem_c.encrypt(unencrypted_str))\n    assert pem_c.decrypt(\n        pem_c.encrypt(unencrypted_str, public_key=pem_c.public_key())\n    ) == pem_c.decrypt(pem_c.encrypt(unencrypted_str))\n    assert pem_c.decrypt(content) == unencrypted_str\n    assert isinstance(content, str)\n    assert pem_c.decrypt(content) == unencrypted_str\n    # support str as well\n    assert pem_c.decrypt(content) == unencrypted_str\n    assert pem_c.decrypt(content.encode(\"utf-8\")) == unencrypted_str\n    # Sign test\n    assert isinstance(pem_c.sign(content.encode(\"utf-8\")), bytes)\n\n    # Web Push handling\n    webpush_content = pem_c.encrypt_webpush(\n        unencrypted_str, public_key=pem_c.public_key(), auth_secret=b\"secret\"\n    )\n    assert isinstance(webpush_content, bytes)\n\n    webpush_content = pem_c.encrypt_webpush(\n        unencrypted_str.encode(\"utf-8\"),\n        public_key=pem_c.public_key(),\n        auth_secret=b\"secret\",\n    )\n    assert isinstance(webpush_content, bytes)\n\n    # Non Bytes (garbage basically)\n    with pytest.raises(TypeError):\n        assert pem_c.decrypt(None) is None\n\n    with pytest.raises(TypeError):\n        assert pem_c.decrypt(5) is None\n\n    with pytest.raises(TypeError):\n        assert pem_c.decrypt(False) is None\n\n    with pytest.raises(TypeError):\n        assert pem_c.decrypt(object) is None\n\n    # Test our initialization\n    pem_c = utils.pem.ApprisePEMController(\n        path=None, prv_keyfile=\"invalid\", asset=asset\n    )\n    assert pem_c.private_keyfile() is False\n    assert pem_c.public_keyfile() is None\n    assert pem_c.prv_keyfile is False\n    assert pem_c.pub_keyfile is None\n    assert pem_c.private_key() is None\n    assert pem_c.public_key() is None\n    assert pem_c.decrypt(content) is None\n\n    pem_c = utils.pem.ApprisePEMController(\n        path=None, pub_keyfile=\"invalid\", asset=asset\n    )\n    assert pem_c.private_keyfile() is None\n    assert pem_c.public_keyfile() is False\n    assert pem_c.prv_keyfile is None\n    assert pem_c.pub_keyfile is False\n    assert pem_c.private_key() is None\n    assert pem_c.public_key() is None\n    assert pem_c.decrypt(content) is None\n\n    pem_c = utils.pem.ApprisePEMController(\n        path=None, prv_keyfile=prv_keyfile, asset=asset\n    )\n    assert pem_c.private_keyfile() == prv_keyfile\n    assert pem_c.public_keyfile() is None\n    assert pem_c.private_key() is not None\n    assert pem_c.prv_keyfile == prv_keyfile\n    assert pem_c.pub_keyfile is None\n    assert pem_c.public_key() is not None\n    assert pem_c.decrypt(content) == unencrypted_str\n\n    pem_c = utils.pem.ApprisePEMController(\n        path=None, pub_keyfile=pub_keyfile, asset=asset\n    )\n    assert pem_c.private_keyfile() is None\n    assert pem_c.public_keyfile() == pub_keyfile\n    assert pem_c.prv_keyfile is None\n    assert pem_c.pub_keyfile == pub_keyfile\n    assert pem_c.private_key() is None\n    assert pem_c.public_key() is not None\n    assert pem_c.decrypt(content) is None\n\n    # Test our path references\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    assert pem_c.load_private_key(path=None) is True\n    assert pem_c.private_keyfile() == prv_keyfile\n    assert pem_c.prv_keyfile is None\n    assert pem_c.pub_keyfile is None\n    assert pem_c.decrypt(content) == unencrypted_str\n\n    # Generate a new key referencing another location\n    pem_c = utils.pem.ApprisePEMController(\n        name=\"keygen-tests\", path=str(tmpdir0), asset=asset\n    )\n\n    # generate ourselves some keys\n    assert pem_c.keygen() is True\n    keygen_prv_file = pem_c.prv_keyfile\n    keygen_pub_file = pem_c.pub_keyfile\n\n    # Remove 1 (but not both)\n    os.unlink(keygen_pub_file)\n\n    pem_c = utils.pem.ApprisePEMController(\n        name=\"keygen-tests\", path=str(tmpdir0), asset=asset\n    )\n    # Private key was found, so this does not work\n    assert pem_c.keygen() is False\n    os.unlink(keygen_prv_file)\n\n    pem_c = utils.pem.ApprisePEMController(\n        name=\"keygen-tests\", path=str(tmpdir0), asset=asset\n    )\n    # It works now\n    assert pem_c.keygen() is True\n\n    # Tests public_key generation failure only\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        assert pem_c.keygen(force=True) is False\n        with mock.patch(\"os.unlink\", side_effect=OSError()):\n            assert pem_c.keygen(force=True) is False\n        with mock.patch(\"os.unlink\", return_value=True):\n            assert pem_c.keygen(force=True) is False\n\n    # Tests private key generation\n    side_effect = [mock.mock_open(read_data=\"file contents\").return_value] + [\n        OSError() for _ in range(10)\n    ]\n    with mock.patch(\"builtins.open\", side_effect=side_effect):\n        assert pem_c.keygen(force=True) is False\n    with (\n        mock.patch(\"builtins.open\", side_effect=side_effect),\n        mock.patch(\"os.unlink\", side_effect=OSError()),\n    ):\n        assert pem_c.keygen(force=True) is False\n    with (\n        mock.patch(\"builtins.open\", side_effect=side_effect),\n        mock.patch(\"os.unlink\", return_value=True),\n    ):\n        assert pem_c.keygen(force=True) is False\n\n    # Generate a new key referencing another location\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    # We can't re-generate keys if ones already exist\n    assert pem_c.keygen() is False\n    # the keygen is the big difference here\n    assert pem_c.keygen(name=\"test\") is True\n    # under the hood, a key is not regenerated (as one already exists)\n    assert pem_c.keygen(name=\"test\") is False\n    # Generate it a second time by force\n    assert pem_c.keygen(name=\"test\", force=True) is True\n\n    assert pem_c.private_keyfile() == os.path.join(\n        str(tmpdir0), \"test-private_key.pem\"\n    )\n    assert pem_c.public_keyfile() == os.path.join(\n        str(tmpdir0), \"test-public_key.pem\"\n    )\n    assert pem_c.private_key() is not None\n    assert pem_c.public_key() is not None\n    assert pem_c.prv_keyfile == os.path.join(\n        str(tmpdir0), \"test-private_key.pem\"\n    )\n    assert pem_c.pub_keyfile == os.path.join(\n        str(tmpdir0), \"test-public_key.pem\"\n    )\n    # 'content' was generated using a different key and can not be\n    # decrypted\n    assert pem_c.decrypt(content) is None\n\n    # Test Decryption files\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    # Calling decrypt triggers underlining code to auto-load\n    assert pem_c.decrypt(content) == unencrypted_str\n    # Using a private key by path\n    assert (\n        pem_c.decrypt(content, private_key=pem_c.private_key())\n        == unencrypted_str\n    )\n\n    # Test different edge cases of load_private_key()\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    assert pem_c.load_private_key() is True\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    assert pem_c.load_private_key(path=prv_keyfile) is True\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    with mock.patch(\"builtins.open\", side_effect=TypeError()):\n        assert pem_c.load_private_key(path=prv_keyfile) is False\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        assert pem_c.load_private_key(path=prv_keyfile) is False\n    with mock.patch(\"builtins.open\", side_effect=FileNotFoundError()):\n        assert pem_c.load_private_key(path=prv_keyfile) is False\n\n    # Test different edge cases of load_public_key()\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    assert pem_c.load_public_key() is True\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    assert pem_c.load_public_key(path=pub_keyfile) is True\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    with mock.patch(\"builtins.open\", side_effect=TypeError()):\n        assert pem_c.load_public_key(path=pub_keyfile) is False\n    with mock.patch(\"builtins.open\", side_effect=OSError()):\n        assert pem_c.load_public_key(path=pub_keyfile) is False\n    with mock.patch(\"builtins.open\", side_effect=FileNotFoundError()):\n        assert pem_c.load_public_key(path=pub_keyfile) is False\n\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    assert pem_c.public_keyfile(\"test1\", \"test2\") == pub_keyfile\n    assert pem_c.private_keyfile(\"test1\", \"test2\") == prv_keyfile\n\n    pem_c = utils.pem.ApprisePEMController(\n        path=str(tmpdir0), name=\"pub1\", asset=asset\n    )\n    assert pem_c.public_key(autogen=True)\n\n    pem_c = utils.pem.ApprisePEMController(\n        path=str(tmpdir0), name=\"pub2\", asset=asset\n    )\n    assert pem_c.private_key(autogen=True)\n\n    #\n    # Auto key generation turned on\n    #\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.MEMORY,\n        storage_path=str(tmpdir0),\n        pem_autogen=True,\n    )\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    assert pem_c.load_public_key(path=pub_keyfile) is True\n    pem_c = utils.pem.ApprisePEMController(path=None, asset=asset)\n    assert pem_c.load_public_key(path=pub_keyfile) is True\n\n    tmpdir1 = tmpdir.mkdir(\"tmp01\")\n\n    # Currently no files here\n    assert os.listdir(str(tmpdir1)) == []\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.MEMORY,\n        storage_path=str(tmpdir1),\n        pem_autogen=False,\n    )\n\n    # Auto-Gen is turned off, so weare not successful here\n    pem_c = utils.pem.ApprisePEMController(path=None, asset=asset)\n    assert pem_c.public_key() is None\n    assert pem_c.private_key() is None\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset)\n    assert pem_c.public_key() is None\n    assert pem_c.private_key() is None\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir1),\n        pem_autogen=True,\n    )\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset)\n    # Generate ourselves a private key\n    assert pem_c.public_key() is not None\n    assert pem_c.private_key() is not None\n    pub_keyfile = os.path.join(str(tmpdir1), \"public_key.pem\")\n    prv_keyfile = os.path.join(str(tmpdir1), \"private_key.pem\")\n    assert os.path.isfile(pub_keyfile)\n    assert os.path.isfile(prv_keyfile)\n\n    with open(pub_keyfile, \"w\") as f:\n        f.write(\"garbage\")\n\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir1), asset=asset)\n    # we can still load our data as the public key is generated\n    # from the private\n    assert pem_c.public_key() is not None\n    assert pem_c.private_key() is not None\n\n    tmpdir2 = tmpdir.mkdir(\"tmp02\")\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir2), asset=asset)\n    pub_keyfile = os.path.join(str(tmpdir2), \"public_key.pem\")\n    prv_keyfile = os.path.join(str(tmpdir2), \"private_key.pem\")\n    assert not os.path.isfile(pub_keyfile)\n    assert not os.path.isfile(prv_keyfile)\n\n    #\n    # Public Key Edge Case Tests\n    #\n    with (\n        mock.patch.object(\n            pem_c, \"public_keyfile\", side_effect=[None, pub_keyfile]\n        ) as mock_keyfile,\n        mock.patch.object(\n            pem_c,\n            \"keygen\",\n            side_effect=lambda *_, **__: setattr(\n                pem_c, \"_ApprisePEMController__public_key\", pubkey_ref\n            )\n            or True,\n        ) as mock_keygen,\n        mock.patch.object(pem_c, \"load_public_key\", return_value=True),\n    ):\n\n        result = pem_c.public_key()\n        assert result is pubkey_ref\n        assert mock_keyfile.call_count == 2\n        mock_keygen.assert_called_once()\n\n    # - First call: None → triggers keygen\n    # - Second call (recursive): None → causes fallback\n    public_keyfile_side_effect = [None, None]\n\n    with (\n        mock.patch.object(\n            pem_c, \"public_keyfile\", side_effect=public_keyfile_side_effect\n        ) as mock_keyfile,\n        mock.patch.object(pem_c, \"keygen\", return_value=True) as mock_keygen,\n        mock.patch.object(\n            pem_c, \"load_public_key\", return_value=False\n        ) as mock_load,\n    ):\n\n        # Ensure no key is preset initially\n        pem_c._ApprisePEMController__public_key = None\n\n        result = pem_c.public_key()\n        assert result is None\n        # Once in outer call, once in recursive\n        assert mock_keyfile.call_count == 2\n        mock_keygen.assert_called_once()\n        mock_load.assert_not_called()\n\n    #\n    # Private Key Edge Case Tests\n    #\n    with (\n        mock.patch.object(\n            pem_c, \"private_keyfile\", side_effect=[None, prv_keyfile]\n        ) as mock_keyfile,\n        mock.patch.object(\n            pem_c,\n            \"keygen\",\n            side_effect=lambda *_, **__: setattr(\n                pem_c, \"_ApprisePEMController__private_key\", prvkey_ref\n            )\n            or True,\n        ) as mock_keygen,\n        mock.patch.object(pem_c, \"load_private_key\", return_value=True),\n    ):\n\n        result = pem_c.private_key()\n        assert result is prvkey_ref\n        assert mock_keyfile.call_count == 2\n        mock_keygen.assert_called_once()\n\n    # - First call: None → triggers keygen\n    # - Second call (recursive): None → causes fallback\n    private_keyfile_side_effect = [None, None]\n\n    with (\n        mock.patch.object(\n            pem_c, \"private_keyfile\", side_effect=private_keyfile_side_effect\n        ) as mock_keyfile,\n        mock.patch.object(pem_c, \"keygen\", return_value=True) as mock_keygen,\n        mock.patch.object(\n            pem_c, \"load_private_key\", return_value=False\n        ) as mock_load,\n    ):\n\n        # Ensure no key is preset initially\n        pem_c._ApprisePEMController__private_key = None\n\n        result = pem_c.private_key()\n        assert result is None\n        # Once in outer call, once in recursive\n        assert mock_keyfile.call_count == 2\n        mock_keygen.assert_called_once()\n        mock_load.assert_not_called()\n\n\n@pytest.mark.skipif(\n    \"cryptography\" in sys.modules,\n    reason=\"Requires that cryptography NOT be installed\",\n)\ndef test_utils_pem_general_without_c(tmpdir):\n    \"\"\"Utils:PEM Without cryptography.\"\"\"\n\n    tmpdir0 = tmpdir.mkdir(\"tmp00\")\n\n    # Currently no files here\n    assert os.listdir(str(tmpdir0)) == []\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.MEMORY,\n        storage_path=str(tmpdir0),\n        pem_autogen=False,\n    )\n\n    # Create a PEM Controller\n    pem_c = utils.pem.ApprisePEMController(path=None, asset=asset)\n\n    # cryptography library missing poses issues with library useage\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.public_keyfile()\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.public_key()\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        _ = pem_c.x962_str\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.encrypt(\"message\")\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.keygen()\n\n    asset = AppriseAsset(\n        storage_mode=PersistentStoreMode.FLUSH,\n        storage_path=str(tmpdir0),\n        pem_autogen=False,\n    )\n\n    # No new files\n    assert os.listdir(str(tmpdir0)) == []\n\n    # Our asset is now write mode, so we will be able to generate a key\n    pem_c = utils.pem.ApprisePEMController(path=str(tmpdir0), asset=asset)\n    # Nothing to lookup\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.private_keyfile()\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.private_key()\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        _ = pem_c.x962_str\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.encrypt(\"message\")\n\n    # Keys can not be generated in memory mode\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.keygen()\n\n    # No files loaded\n    assert os.listdir(str(tmpdir0)) == []\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.public_keyfile()\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.public_key()\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        _ = pem_c.x962_str\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.encrypt(\"message\")\n\n    with pytest.raises(utils.pem.ApprisePEMException):\n        pem_c.decrypt(\"abcd==\")\n"
  },
  {
    "path": "tests/test_utils_sanitize.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\n\"\"\"Unit tests for :mod:`apprise.utils.sanitize`.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom hashlib import sha256\nimport logging\nimport sys\n\nimport pytest\n\nfrom apprise.utils.sanitize import SanitizeOptions, sanitize_payload\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\nsys.dont_write_bytecode = True\n\n\nclass _ReprOnly:\n    \"\"\"Helper type that only supports repr(), used to cover fallback paths.\"\"\"\n\n    def __repr__(self) -> str:\n        return \"<repr-only>\"\n\n\ndef test_sanitize_payload_passthrough_primitives() -> None:\n    \"\"\"Primitives should pass through with minimal transformation.\"\"\"\n    assert sanitize_payload(None) is None\n    assert sanitize_payload(True) is True\n    assert sanitize_payload(False) is False\n    assert sanitize_payload(123) == 123\n    assert sanitize_payload(3.14) == pytest.approx(3.14)\n\n\ndef test_sanitize_payload_small_string_passthrough() -> None:\n    \"\"\"Small strings are not altered, preserving debugging usefulness.\"\"\"\n    s = \"hello world\"\n    assert sanitize_payload(s) == s\n\n\ndef test_sanitize_payload_large_string_is_summarized() -> None:\n    \"\"\"Strings beyond max_str_len are summarized with head/tail previews.\"\"\"\n    opts = SanitizeOptions(max_str_len=64, preview=8)\n    s = \"x\" * 200\n    out = sanitize_payload(s, options=opts)\n\n    assert isinstance(out, str)\n    assert out.startswith(\"<string len=200\")\n    assert \"head=\" in out\n    assert \"tail=\" in out\n\n\ndef test_sanitize_payload_bytes_are_summarized_and_hash_is_bounded() -> None:\n    \"\"\"Bytes are always summarized with a bounded sha256 digest.\"\"\"\n    opts = SanitizeOptions(hash_sample_size=8)\n    b = b\"01234567\" + b\"EXTRA-DATA\"\n\n    out = sanitize_payload(b, options=opts)\n    assert isinstance(out, str)\n    assert out.startswith(f\"<bytes len={len(b)}\")\n\n    expected = sha256(b[: opts.hash_sample_size]).hexdigest()[:12]\n    assert f\"sha256={expected}\" in out\n\n\ndef test_sanitize_payload_dict_keys_are_sanitised_for_bytes() -> None:\n    \"\"\"Bytes keys become readable string markers rather than raw bytes.\"\"\"\n    opts = SanitizeOptions(hash_sample_size=16)\n\n    bkey = b\"abc\"\n    payload = {\n        bkey: \"value1\",\n        \"k\": \"v\",\n    }\n\n    out = sanitize_payload(payload, options=opts)\n    assert isinstance(out, dict)\n\n    expected_b_digest = sha256(bkey[: opts.hash_sample_size]).hexdigest()[:12]\n    bkey_s = f\"<bytes len={len(bkey)} sha256={expected_b_digest}>\"\n    assert bkey_s in out\n    assert out[bkey_s] == \"value1\"\n    assert out[\"k\"] == \"v\"\n\n\ndef test_sanitize_payload_sequence_types() -> None:\n    \"\"\"Lists, tuples, sets, and frozensets are walked safely.\"\"\"\n    payload = [1, \"x\" * 200, b\"xyz\"]\n    opts = SanitizeOptions(max_str_len=64, preview=8)\n\n    out = sanitize_payload(payload, options=opts)\n    assert isinstance(out, list)\n    assert out[0] == 1\n    assert isinstance(out[1], str) and out[1].startswith(\"<string len=\")\n    assert isinstance(out[2], str) and out[2].startswith(\"<bytes len=3\")\n\n    out_t = sanitize_payload(tuple(payload), options=opts)\n    assert isinstance(out_t, tuple)\n\n    # Sets are returned as lists for readability in logs.\n    out_s = sanitize_payload(set(payload), options=opts)\n    assert isinstance(out_s, list)\n\n    out_fs = sanitize_payload(frozenset(payload), options=opts)\n    assert isinstance(out_fs, list)\n\n\ndef test_sanitize_payload_recursive_structure_is_detected() -> None:\n    \"\"\"Self-referential objects should not trigger infinite recursion.\"\"\"\n    payload: dict[str, object] = {}\n    payload[\"self\"] = payload\n\n    out = sanitize_payload(payload)\n    assert isinstance(out, dict)\n    assert out[\"self\"] == \"<recursive>\"\n\n\ndef test_sanitize_payload_max_depth_truncation() -> None:\n    \"\"\"Depth limits protect against overly deep nested structures.\"\"\"\n    opts = SanitizeOptions(max_depth=2)\n    payload = {\"a\": {\"b\": {\"c\": \"d\"}}}\n\n    out = sanitize_payload(payload, options=opts)\n    assert isinstance(out, dict)\n    assert isinstance(out[\"a\"], dict)\n    assert isinstance(out[\"a\"][\"b\"], dict)\n    assert out[\"a\"][\"b\"][\"c\"] == \"<truncated: max depth reached>\"\n\n\ndef test_sanitize_payload_max_items_truncation_in_list_branch() -> None:\n    \"\"\"Item limits protect against very large sequences.\"\"\"\n    opts = SanitizeOptions(max_items=2)\n    payload = [1, 2, 3, 4]\n\n    out = sanitize_payload(payload, options=opts)\n    assert isinstance(out, list)\n    assert out[-1] == \"<truncated: limit reached>\"\n\n\ndef test_sanitize_payload_global_item_limit_guard_message() -> None:\n    \"\"\"The global max_items guard stops work consistently.\"\"\"\n    opts = SanitizeOptions(max_items=1)\n    payload = {\"a\": \"x\", \"b\": \"y\"}\n\n    out = sanitize_payload(payload, options=opts)\n    assert isinstance(out, dict)\n    assert any(\n        v == \"<truncated: global item limit reached>\" or v == \"...\"\n        for v in out.values()\n    )\n\n\ndef test_sanitize_payload_falls_back_to_repr_for_unknown_objects() -> None:\n    \"\"\"Unknown objects are converted with repr() for logging.\"\"\"\n    assert sanitize_payload(_ReprOnly()) == \"<repr-only>\"\n\n\ndef test_sanitize_payload_blob_key_enables_blob_mode_and_summarizes() -> None:\n    \"\"\"Blob-like keys enable blob_mode and trigger <blob-string ...>.\"\"\"\n    opts = SanitizeOptions(\n        aggressive_blob_keys=True,\n        # ensure normal summarization would NOT trigger\n        max_str_len=999999,\n        preview=8,\n    )\n\n    payload = {\n        # small string, but blob_mode forces summary\n        \"base64_attachments\": \"ABCDEF\",\n    }\n\n    out = sanitize_payload(payload, options=opts)\n\n    assert isinstance(out, dict)\n    assert \"base64_attachments\" in out\n    assert isinstance(out[\"base64_attachments\"], str)\n    assert out[\"base64_attachments\"].startswith(\"<string len=6 blob \")\n    assert \"head=\" in out[\"base64_attachments\"]\n    assert \"tail=\" in out[\"base64_attachments\"]\n\n\ndef test_sanitize_payload_dict_key_passthrough_for_non_str_bytes_key() -> None:\n    \"\"\"\n    Verify non-string, non-bytes dictionary keys are preserved unchanged.\n\n    This exercises the fallback path in _summarize_key(), ensuring unexpected\n    key types (such as integers) are passed through safely during sanitisation.\n    \"\"\"\n    payload = {\n        # int key triggers the \"return k\" branch\n        42: \"value\",\n        # keep a normal key too\n        \"k\": \"v\",\n    }\n\n    out = sanitize_payload(payload)\n\n    assert isinstance(out, dict)\n    assert 42 in out\n    assert out[42] == \"value\"\n    assert out[\"k\"] == \"v\"\n"
  },
  {
    "path": "tests/test_utils_socket.py",
    "content": "# BSD 2-Clause License\n#\n# Apprise - Push Notification Library.\n# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>\n#\n# Redistribution and use in source and binary forms, with or without\n# modification, are permitted provided that the following conditions are met:\n#\n# 1. Redistributions of source code must retain the above copyright notice,\n#    this list of conditions and the following disclaimer.\n#\n# 2. Redistributions in binary form must reproduce the above copyright notice,\n#    this list of conditions and the following disclaimer in the documentation\n#    and/or other materials provided with the distribution.\n#\n# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE\n# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE\n# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR\n# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF\n# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS\n# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN\n# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)\n# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE\n# POSSIBILITY OF SUCH DAMAGE.\n\nimport logging\nimport ssl\nfrom unittest import mock\n\nimport pytest\n\nfrom apprise.exception import AppriseException, AppriseInvalidData\nfrom apprise.utils.socket import AppriseSocketError, SocketTransport\n\n# Disable logging for a cleaner testing output\nlogging.disable(logging.CRITICAL)\n\n\nclass _DummyFile:\n    def __init__(self) -> None:\n        self.flushed = 0\n        self.closed = 0\n\n    def flush(self) -> None:\n        self.flushed += 1\n\n    def close(self) -> None:\n        self.closed += 1\n\n\nclass _DummySocket:\n    \"\"\"\n    Minimal fake socket object, supports the subset used by SocketTransport.\n    \"\"\"\n\n    def __init__(self) -> None:\n        self.blocking = True\n        self.timeout = None\n        self.closed = False\n        self.shutdown_called = 0\n\n        self._recv_side_effect = None\n        self._send_side_effect = None\n\n    def setsockopt(self, *_args, **_kwargs) -> None:\n        return None\n\n    def bind(self, *_args, **_kwargs) -> None:\n        return None\n\n    def fileno(self) -> int:\n        return 1\n\n    def settimeout(self, value):\n        self.timeout = value\n\n    def setblocking(self, value):\n        self.blocking = bool(value)\n\n    def connect(self, *_args, **_kwargs) -> None:\n        return None\n\n    def shutdown(self, *_args, **_kwargs) -> None:\n        self.shutdown_called += 1\n\n    def close(self) -> None:\n        self.closed = True\n\n    def getsockname(self):\n        return (\"127.0.0.1\", 12345)\n\n    def getpeername(self):\n        return (\"203.0.113.10\", 6667)\n\n    def makefile(self, *_args, **_kwargs):\n        return _DummyFile()\n\n    def recv(self, *_args, **_kwargs):\n        if self._recv_side_effect is not None:\n            raise self._recv_side_effect\n        return b\"data\"\n\n    def send(self, *_args, **_kwargs):\n        if self._send_side_effect is not None:\n            raise self._send_side_effect\n        return 4\n\n\ndef test_utils_socket_timeout_coerce():\n    \"\"\"SocketTransport() Timeout Coercion.\"\"\"\n    # None => None, None\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    assert s._connect_timeout is None\n    assert s._read_timeout is None\n\n    # float => both\n    s = SocketTransport(\"example.com\", 1, timeout=2.5)\n    assert s._connect_timeout == pytest.approx(2.5)\n    assert s._read_timeout == pytest.approx(2.5)\n\n    # tuple => (connect, read)\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 3.0))\n    assert s._connect_timeout == pytest.approx(1.0)\n    assert s._read_timeout == pytest.approx(3.0)\n\n    # tuple with None is allowed\n    s = SocketTransport(\"example.com\", 1, timeout=(None, 3.0))\n    assert s._connect_timeout is None\n    assert s._read_timeout == pytest.approx(3.0)\n\n    # invalid types\n    with pytest.raises(AppriseInvalidData):\n        SocketTransport(\"example.com\", 1, timeout=\"bad\")\n\n    with pytest.raises(AppriseInvalidData):\n        SocketTransport(\"example.com\", 1, timeout=(1.0, 2.0, 3.0))\n\n    with pytest.raises(AppriseInvalidData):\n        SocketTransport(\"example.com\", 1, timeout=-1.0)\n\n\ndef test_utils_socket_properties_and_close_paths():\n    \"\"\"SocketTransport() Properties And Close Paths.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    assert s.connected is False\n    assert s.is_tls is False\n\n    # Seed with dummy socket + wrappers\n    s._sock = _DummySocket()\n    s._rfile = _DummyFile()\n    s._wfile = _DummyFile()\n    s._is_tls = True\n    s.local_addr = (\"1.1.1.1\", 1)\n    s.remote_addr = (\"2.2.2.2\", 2)\n\n    s.close()\n    assert s._sock is None\n    assert s._rfile is None\n    assert s._wfile is None\n    assert s._is_tls is False\n    assert s.local_addr is None\n    assert s.remote_addr is None\n\n\ndef test_utils_socket_read_write_no_socket():\n    \"\"\"SocketTransport() Can Read/Write - No Socket.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    assert s.can_read() is None\n    assert s.can_write() is None\n\n\ndef test_utils_socket_can_read_write_select_error():\n    \"\"\"SocketTransport() Can Read/Write - select() Error.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    s._sock = _DummySocket()\n\n    with mock.patch(\"select.select\", side_effect=OSError()):\n        assert s.can_read() is None\n        assert s._sock is None\n\n    s._sock = _DummySocket()\n    with mock.patch(\"select.select\", side_effect=OSError()):\n        assert s.can_write() is None\n        assert s._sock is None\n\n\ndef test_utils_socket_can_read_write_socket_close():\n    \"\"\"SocketTransport() Can Read/Write - Socket Close.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    s._sock = _DummySocket()\n\n    # Exceptional socket list returned triggers close()\n    with mock.patch(\"select.select\", return_value=([], [], [s._sock])):\n        assert s.can_read() is None\n        assert s._sock is None\n\n    s._sock = _DummySocket()\n    with mock.patch(\"select.select\", return_value=([], [], [s._sock])):\n        assert s.can_write() is None\n        assert s._sock is None\n\n\ndef test_utils_socket_server_hostname_for_tls():\n    \"\"\"SocketTransport() Server Hostname For TLS.\"\"\"\n    s = SocketTransport(\"irc.example.com\", 1, verify=True)\n    assert s._server_hostname_for_tls() == \"irc.example.com\"\n\n    s = SocketTransport(\"irc.example.com\", 1, verify=False)\n    assert s._server_hostname_for_tls() == \"irc.example.com\"\n\n\ndef test_utils_socket_server_hostname_for_tls_ip_reverse():\n    \"\"\"SocketTransport() Server Hostname For TLS IP Reverse DNS\"\"\"\n    s = SocketTransport(\"203.0.113.10\", 1, verify=True)\n\n    with mock.patch(\n            \"socket.gethostbyaddr\",\n            return_value=(\"irc.example.com.\", [], [])):\n        assert s._server_hostname_for_tls() == \"irc.example.com\"\n\n    with mock.patch(\"socket.gethostbyaddr\", side_effect=OSError()):\n        assert s._server_hostname_for_tls() == \"203.0.113.10\"\n\n    # verify=False => no lookup\n    s = SocketTransport(\"203.0.113.10\", 1, verify=False)\n    with mock.patch(\"socket.gethostbyaddr\") as m:\n        assert s._server_hostname_for_tls() == \"203.0.113.10\"\n        m.assert_not_called()\n\n\ndef test_utils_socket_build_ssl_context():\n    \"\"\"SocketTransport() Build SSL Context.\"\"\"\n\n    class _DummySSLContext:\n        def __init__(self, *, raise_on_minimum: bool) -> None:\n            self.raise_on_minimum = raise_on_minimum\n            self.options = 0\n            self.check_hostname = None\n            self.verify_mode = None\n            self._minimum_version = None\n\n        @property\n        def minimum_version(self):\n            return self._minimum_version\n\n        @minimum_version.setter\n        def minimum_version(self, value):\n            if self.raise_on_minimum:\n                raise RuntimeError(\"minimum_version not supported\")\n            self._minimum_version = value\n\n    # verify=True path with minimum_version support\n    s = SocketTransport(\"example.com\", 1, verify=True)\n    ctx = _DummySSLContext(raise_on_minimum=False)\n\n    with mock.patch(\"certifi.where\", return_value=\"/tmp/ca.pem\"), mock.patch(\n        \"ssl.create_default_context\", return_value=ctx\n    ) as m:\n        result = s._build_ssl_context()\n        assert result is ctx\n        m.assert_called_once()\n        assert ctx.check_hostname is True\n        assert ctx.verify_mode == ssl.CERT_REQUIRED\n\n        # TLS 1.2+ preferred path\n        assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2\n\n        # Compression disabled when supported\n        if hasattr(ssl, \"OP_NO_COMPRESSION\"):\n            assert (ctx.options & ssl.OP_NO_COMPRESSION) != 0\n\n    # verify=False path still enforces TLS 1.2+\n    s = SocketTransport(\"example.com\", 1, verify=False)\n    ctx = _DummySSLContext(raise_on_minimum=False)\n\n    with mock.patch(\"ssl.create_default_context\", return_value=ctx):\n        result = s._build_ssl_context()\n        assert result is ctx\n        assert ctx.check_hostname is False\n        assert ctx.verify_mode == ssl.CERT_NONE\n        assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2\n\n    # Fallback path: minimum_version setter fails, options are used instead\n    s = SocketTransport(\"example.com\", 1, verify=True)\n    ctx = _DummySSLContext(raise_on_minimum=True)\n\n    with mock.patch(\"certifi.where\", return_value=\"/tmp/ca.pem\"), mock.patch(\n        \"ssl.create_default_context\", return_value=ctx\n    ):\n        result = s._build_ssl_context()\n        assert result is ctx\n\n        # minimum_version was not set\n        assert ctx.minimum_version is None\n\n        # Fallback disables older protocols when options are present\n        if hasattr(ssl, \"OP_NO_TLSv1\"):\n            assert (ctx.options & ssl.OP_NO_TLSv1) != 0\n        if hasattr(ssl, \"OP_NO_TLSv1_1\"):\n            assert (ctx.options & ssl.OP_NO_TLSv1_1) != 0\n\n\ndef test_utils_socket_start_tls_no_socket_raises():\n    \"\"\"SocketTransport() Start TLS No Socket Raises.\"\"\"\n    s = SocketTransport(\"example.com\", 1, secure=True)\n    with pytest.raises(AppriseSocketError):\n        s.start_tls()\n\n\ndef test_utils_socket_start_tls_already_tls_noop():\n    \"\"\"SocketTransport() Start TLS Already TLS Noop.\"\"\"\n    s = SocketTransport(\"example.com\", 1, secure=True)\n    s._sock = _DummySocket()\n    s._is_tls = True\n    s.start_tls()\n    assert s._sock is not None\n    assert s._is_tls is True\n\n\ndef test_utils_socket_tls_ssl_init_errors():\n    \"\"\"SocketTransport() Start TLS/SSL Initialization Errors.\"\"\"\n    s = SocketTransport(\"example.com\", 1, secure=True)\n    s._sock = _DummySocket()\n\n    with mock.patch.object(s, \"_build_ssl_context\") as mctx:\n        ctx = mock.Mock()\n        ctx.wrap_socket.side_effect = ssl.SSLError(\"boom\")\n        mctx.return_value = ctx\n\n        with pytest.raises(AppriseSocketError):\n            s.start_tls()\n        assert s._sock is None\n\n    s._sock = _DummySocket()\n    with mock.patch.object(s, \"_build_ssl_context\") as mctx:\n        ctx = mock.Mock()\n        ctx.wrap_socket.side_effect = OSError(\"boom\")\n        mctx.return_value = ctx\n\n        with pytest.raises(AppriseSocketError):\n            s.start_tls()\n        assert s._sock is None\n\n\ndef test_utils_socket_tls_tests():\n    \"\"\"SocketTransport() TLS Coverage\"\"\"\n    s = SocketTransport(\"example.com\", 1, secure=True)\n    base_sock = _DummySocket()\n    s._sock = base_sock\n\n    tls_sock = _DummySocket()\n    ctx = mock.Mock()\n    ctx.wrap_socket.return_value = tls_sock\n\n    with mock.patch.object(\n            s, \"_build_ssl_context\",\n            return_value=ctx), mock.patch.object(\n        s, \"_server_hostname_for_tls\", return_value=\"example.com\"\n    ):\n        s.start_tls()\n\n    assert s._sock is tls_sock\n    assert s.is_tls is True\n    assert s.local_addr == (\"127.0.0.1\", 12345)\n    assert s.remote_addr == (\"203.0.113.10\", 6667)\n    assert s._rfile is not None\n    assert s._wfile is not None\n\n\ndef test_utils_socket_connect():\n    \"\"\"SocketTransport() Connect() Coverage.\"\"\"\n    s = SocketTransport(\"example.com\", 6667, secure=False, timeout=(1.0, 2.0))\n\n    dummy = _DummySocket()\n\n    with mock.patch(\"socket.socket\", return_value=dummy):\n        s.connect()\n        assert s.connected is True\n        assert s.is_tls is False\n        assert s.local_addr == (\"127.0.0.1\", 12345)\n        assert s.remote_addr == (\"203.0.113.10\", 6667)\n\n    # Failure path: connect raises\n    s = SocketTransport(\"example.com\", 6667, secure=False, timeout=(1.0, 2.0))\n    dummy = _DummySocket()\n    dummy.connect = mock.Mock(side_effect=OSError(\"fail\"))\n\n    with mock.patch(\"socket.socket\", return_value=dummy):\n        with pytest.raises(AppriseSocketError):\n            s.connect()\n        assert s.connected is False\n\n\ndef test_utils_socket_read_nonblocking():\n    \"\"\"SocketTransport() Read Nonblocking.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    sock = _DummySocket()\n    s._sock = sock\n\n    # Non-blocking success\n    assert s.read(blocking=False) == b\"data\"\n\n    # Non-blocking no data\n    sock._recv_side_effect = BlockingIOError()\n    assert s.read(blocking=False) == b\"\"\n\n    # Non-blocking OSError -> close + raise\n    sock._recv_side_effect = OSError(\"boom\")\n    with pytest.raises(AppriseSocketError):\n        s.read(blocking=False)\n    assert s._sock is None\n\n\ndef test_utils_socket_read_blocking_timeout():\n    \"\"\"SocketTransport() Read Blocking Timeout.\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.1))\n    sock = _DummySocket()\n    s._sock = sock\n\n    # Timeout path: can_read returns False => b\"\"\n    with mock.patch.object(s, \"can_read\", return_value=False):\n        assert s.read(blocking=True) == b\"\"\n\n    # Indefinite path: wait_timeout=None, loop until can_read True\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    sock = _DummySocket()\n    s._sock = sock\n\n    side_effect = [False, False, True]\n    with mock.patch.object(s, \"can_read\", side_effect=side_effect):\n        assert s.read(blocking=True) == b\"data\"\n\n\ndef test_utils_socket_read_blocking_edge_cases() -> None:\n    \"\"\"SocketTransport() Read Blocking Edge Cases\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n\n    bad_sock = _DummySocket()\n    good_sock = _DummySocket()\n    good_sock.recv = mock.Mock(return_value=b\"data\")\n\n    s._sock = bad_sock\n    s._had_io = True\n\n    def _close_side_effect() -> None:\n        # emulate closing the socket\n        if s._sock is not None:\n            s._sock = None\n        s._rfile = None\n        s._wfile = None\n        s._is_tls = False\n        s.local_addr = None\n        s.remote_addr = None\n\n    def _connect_side_effect() -> None:\n        s._sock = good_sock\n        s._refresh_wrappers()\n\n    with (\n        mock.patch.object(s, \"close\", side_effect=_close_side_effect),\n        mock.patch.object(s, \"connect\", side_effect=_connect_side_effect),\n        mock.patch.object(s, \"can_read\", return_value=None),\n    ):\n        # can_read(None) triggers reconnect branch, then recv() on new socket\n        assert s.read(blocking=True, retries=1) == b\"data\"\n\n\ndef test_utils_socket_write_with_no_socket():\n    \"\"\"SocketTransport() write() - No Socket.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    with pytest.raises(AppriseSocketError):\n        s.write(b\"hi\")\n\n    s._sock = _DummySocket()\n    with pytest.raises(AppriseInvalidData):\n        s.write(\"hi\")  # type: ignore[arg-type]\n\n\ndef test_utils_socket_write_flush():\n    \"\"\"SocketTransport() write() - Flush.\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.2))\n    sock = _DummySocket()\n    s._sock = sock\n    s._wfile = _DummyFile()\n\n    # Normal send, flush=True\n    with mock.patch.object(s, \"can_write\", return_value=True):\n        assert s.write(b\"test\", flush=True) == 4\n    assert s._wfile.flushed >= 1\n\n    # send returning 0 triggers connection lost\n    sock = _DummySocket()\n    sock.send = mock.Mock(return_value=0)\n    s._sock = sock\n    with pytest.raises(AppriseSocketError):\n        s.write(b\"test\", timeout=0.1)\n\n    # Timeout path: can_write returns False\n    sock = _DummySocket()\n    s._sock = sock\n    with (mock.patch.object(s, \"can_write\",\n                            return_value=False),\n            pytest.raises(AppriseSocketError)):\n        s.write(b\"test\", timeout=0.1)\n\n    # OSError triggers close\n    sock = _DummySocket()\n    sock._send_side_effect = OSError(\"boom\")\n    s._sock = sock\n    with pytest.raises(AppriseSocketError):\n        s.write(b\"test\", timeout=0.1)\n    assert s._sock is None\n\n\ndef test_utils_socket_read_timeouts():\n    \"\"\"SocketTransport() Read Timeouts\"\"\"\n    # Negative connect timeout\n    with pytest.raises(AppriseInvalidData):\n        SocketTransport(\"example.com\", 1, timeout=(-0.1, 1.0))\n\n    # Negative read timeout\n    with pytest.raises(AppriseInvalidData):\n        SocketTransport(\"example.com\", 1, timeout=(1.0, -0.1))\n\n    # Force the read_t is None branch\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, None))\n    assert s._connect_timeout == pytest.approx(1.0)\n    assert s._read_timeout is None\n\n\ndef test_utils_socket_exceptions():\n    \"\"\"SocketTransport() Exception Testing.\"\"\"\n    assert issubclass(AppriseSocketError, AppriseException)\n\n\ndef test_utils_socket_close_exceptions():\n\n    \"\"\"SocketTransport() Close Exceptions.\"\"\"\n    class _BadShutdownSocket(_DummySocket):\n        def shutdown(self, *_args, **_kwargs) -> None:\n            raise Exception(\"shutdown fail\")\n\n    class _BadFile(_DummyFile):\n        def flush(self) -> None:\n            raise Exception(\"flush fail\")\n\n        def close(self) -> None:\n            raise Exception(\"close fail\")\n\n    s = SocketTransport(\"example.com\", 1)\n    s._sock = _BadShutdownSocket()\n    s._wfile = _BadFile()\n    s._rfile = _BadFile()\n\n    # Should not raise, despite flush/close/shutdown throwing\n    s.close()\n\n    assert s._sock is None\n    assert s._wfile is None\n    assert s._rfile is None\n\n\ndef test_utils_socket_refresh():\n    \"\"\"SocketTransport() Refresh.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    s._sock = None\n    s._rfile = _DummyFile()\n    s._wfile = _DummyFile()\n\n    s._refresh_wrappers()\n\n    assert s._rfile is None\n    assert s._wfile is None\n\n\ndef test_utils_socket_can_read_returns_bool():\n    \"\"\"SocketTransport() Can Read Returns Bool.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    s._sock = _DummySocket()\n\n    with mock.patch(\"select.select\", return_value=([s._sock], [], [])):\n        assert s.can_read(0.01) is True\n\n    with mock.patch(\"select.select\", return_value=([], [], [])):\n        assert s.can_read(0.01) is False\n\n\ndef test_utils_socket_connect_bind():\n    \"\"\"SocketTransport() bind() tests\"\"\"\n    s = SocketTransport(\n        \"example.com\", 6667, bind_addr=\"127.0.0.1\", bind_port=0)\n    dummy = _DummySocket()\n    dummy.bind = mock.Mock()\n\n    with mock.patch(\"socket.socket\", return_value=dummy):\n        s.connect()\n\n    dummy.bind.assert_called_once()\n\n\ndef test_utils_socket_connect_settimeout_handling():\n    \"\"\"SocketTransport() Connect settimeout() handling.\"\"\"\n    s = SocketTransport(\"example.com\", 6667, timeout=None)\n    dummy = _DummySocket()\n    dummy.settimeout = mock.Mock()\n\n    with mock.patch(\"socket.socket\", return_value=dummy):\n        s.connect()\n\n    # called once with None after connect() (sock.settimeout(None))\n    # but NOT called with a float connect timeout prior to connect.\n    assert dummy.settimeout.call_args_list == [mock.call(None)]\n\n\ndef test_utils_socket_secure_connect():\n    \"\"\"SocketTransport() Secure connect().\"\"\"\n    s = SocketTransport(\"example.com\", 6667, secure=True)\n    dummy = _DummySocket()\n\n    with mock.patch(\"socket.socket\", return_value=dummy), mock.patch.object(\n        s, \"start_tls\", autospec=True\n    ) as m:\n        s.connect()\n        m.assert_called_once()\n\n\ndef test_utils_socket_connect_exceptions():\n    \"\"\"SocketTransport() Connect Exceptions.\"\"\"\n    class _BadCloseSocket(_DummySocket):\n        def connect(self, *_args, **_kwargs) -> None:\n            raise OSError(\"connect fail\")\n\n        def close(self) -> None:\n            raise Exception(\"close fail\")\n\n    s = SocketTransport(\"example.com\", 6667, secure=False)\n    dummy = _BadCloseSocket()\n\n    with (mock.patch(\"socket.socket\", return_value=dummy),\n          pytest.raises(AppriseSocketError)):\n        s.connect()\n\n    # transport should not keep the socket\n    assert s.connected is False\n\n\ndef test_utils_socket_write_deadline():\n    \"\"\"SocketTransport() Write Deadline.\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.2))\n    sock = _DummySocket()\n    s._sock = sock\n\n    with mock.patch.object(s, \"can_write\", return_value=True) as m_can_write:\n        written = s.write(b\"test\", timeout=0.1, flush=False)\n        assert written == 4\n        assert m_can_write.called\n\n\ndef test_utils_socket_write_timeout():\n    \"\"\"SocketTransport() write() timeout.\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.2))\n    s._sock = _DummySocket()\n\n    # First monotonic call sets deadline, second call makes remaining <= 0\n    with mock.patch(\"time.monotonic\", side_effect=[1000.0, 1000.5]):\n        with pytest.raises(AppriseSocketError) as e:\n            s.write(b\"test\", timeout=0.1, flush=False)\n        assert \"Timed out during write\" in str(e.value)\n\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    sock = _DummySocket()\n    s._sock = sock\n    s._wfile = _DummyFile()\n\n    # If deadline is None, can_write should never be called\n    with mock.patch.object(s, \"can_write\") as m_can_write:\n        result = s.write(b\"test\", flush=True, timeout=None)\n        assert result == 4\n        m_can_write.assert_not_called()\n\n\ndef test_utils_socket_write_flush_edge_cases():\n    \"\"\"SocketTransport() write() flush() edge cases.\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.2))\n    s._sock = _DummySocket()\n    s._wfile = _DummyFile()\n\n    with mock.patch.object(s._wfile, \"flush\") as m:\n        with mock.patch.object(s, \"can_write\", return_value=True):\n            written = s.write(b\"test\", flush=False, timeout=0.1)\n            assert written == 4\n        m.assert_not_called()\n\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.2))\n    s._sock = _DummySocket()\n    s._wfile = None\n\n    # Should succeed and simply skip flush\n    with mock.patch.object(s, \"can_write\", return_value=True):\n        assert s.write(b\"test\", flush=True, timeout=0.1) == 4\n\n\ndef test_utils_socket_recv_error():\n    \"\"\"SocketTransport() recv() errors\n\n    Covers blocking read() OSError handling after readiness check.\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.5))\n    sock = _DummySocket()\n    sock._recv_side_effect = OSError(\"recv failed\")\n    s._sock = sock\n\n    with (mock.patch.object(s, \"can_read\", return_value=True),\n          pytest.raises(AppriseSocketError) as e):\n        s.read(blocking=True)\n\n    assert \"recv failed\" in str(e.value)\n    assert s._sock is None\n\n\ndef test_utils_socket_write_empty_payload_does_not_set_had_io() -> None:\n    \"\"\"SocketTransport() Write Empty Payload Does Not Set Had Io.\n\n    Covers the branch where total_sent == 0, so _had_io is not updated.\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    s._sock = _DummySocket()\n    s._wfile = _DummyFile()\n\n    # Empty payload performs no send() calls\n    sent = s.write(b\"\", flush=False, timeout=None)\n    assert sent == 0\n    assert s._had_io is False\n\n\ndef test_utils_socket_write_handling() -> None:\n    \"\"\"SocketTransport() Write handling\n\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    s._sock = _DummySocket()\n\n    # Mark prior I/O so reconnect is considered eligible in real logic, but we\n    # patch _attempt_reconnect to force the continue path deterministically.\n    s._had_io = True\n\n    # Force the send to fail with an AppriseSocketError\n    s._sock.send = mock.Mock(side_effect=AppriseSocketError(\"boom\"))\n\n    # retries=0 => attempts == 1; we force _attempt_reconnect to return True,\n    # which triggers `continue` and immediately exhausts the loop.\n    with (\n        mock.patch.object(s, \"_attempt_reconnect\", return_value=True),\n        pytest.raises(AppriseSocketError) as e,\n    ):\n        s.write(b\"test\", timeout=None, retries=0)\n\n    assert \"Socket write failed\" in str(e.value)\n\n\ndef test_utils_socket_write_reconnect_continue_path_is_reached() -> None:\n    \"\"\"SocketTransport() Write Reconnect Continue Path Is Reached.\n\n    Covers the `continue` path in write() when _attempt_reconnect returns True.\n    Trigger AppriseSocketError (not OSError) so we do not reset _had_io via.\n    close() before reconnect eligibility is checked.\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n\n    bad_sock = _DummySocket()\n    # send returning 0 triggers \"Connection lost during write\"\n    bad_sock.send = mock.Mock(return_value=0)\n\n    good_sock = _DummySocket()\n    good_sock.send = mock.Mock(return_value=4)\n\n    s._sock = bad_sock\n    s._had_io = True\n\n    def _connect_side_effect() -> None:\n        s._sock = good_sock\n        s._refresh_wrappers()\n\n    with mock.patch.object(s, \"connect\", side_effect=_connect_side_effect):\n        assert s.write(b\"test\", timeout=None, retries=1, flush=False) == 4\n\n\ndef test_utils_socket_read_reconnect_continue_path_is_reached() -> None:\n    \"\"\"SocketTransport() Read Reconnect Continue Path Is Reached.\n\n    Covers the `continue` path in read() when _attempt_reconnect returns True.\n    Trigger AppriseSocketError (not OSError) so we do not reset _had_io via.\n    close() before reconnect eligibility is checked.\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n\n    bad_sock = _DummySocket()\n    # recv returning b\"\" triggers \"Connection lost during read\"\n    bad_sock.recv = mock.Mock(return_value=b\"\")\n\n    good_sock = _DummySocket()\n    good_sock.recv = mock.Mock(return_value=b\"data\")\n\n    s._sock = bad_sock\n    s._had_io = True\n\n    def _connect_side_effect() -> None:\n        s._sock = good_sock\n        s._refresh_wrappers()\n\n    with mock.patch.object(s, \"connect\", side_effect=_connect_side_effect):\n        assert s.read(blocking=False, retries=1) == b\"data\"\n\n\ndef test_utils_socket_attempt_reconnect_retries_zero_returns_false() -> None:\n    \"\"\"SocketTransport() Attempt Reconnect Retries Zero Returns False.\n\n    Covers _attempt_reconnect() early return when retries <= 0.\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    s._had_io = True\n    assert (\n        s._attempt_reconnect(\n            retries=0,\n            action=\"read\",\n            exc=Exception(\"boom\"),\n        )\n        is False\n    )\n\n\ndef test_utils_socket_read_blocking_connection() -> None:\n    \"\"\"SocketTransport() Read Blocking Connection.\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.5))\n    sock = _DummySocket()\n    sock.recv = mock.Mock(return_value=b\"\")\n    s._sock = sock\n\n    # Mark prior I/O, but retries=0 means reconnect is not allowed anyway\n    s._had_io = True\n\n    with (\n        mock.patch.object(s, \"can_read\", return_value=True),\n        pytest.raises(AppriseSocketError) as e,\n    ):\n        s.read(blocking=True, retries=0)\n\n    assert \"Connection lost during read\" in str(e.value)\n\n\ndef test_utils_socket_read_blocking() -> None:\n    \"\"\"SocketTransport() Read Blocking\n    \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.5))\n\n    bad_sock = _DummySocket()\n    bad_sock.recv = mock.Mock(return_value=b\"\")\n    good_sock = _DummySocket()\n    good_sock.recv = mock.Mock(return_value=b\"data\")\n\n    s._sock = bad_sock\n    s._had_io = True\n\n    def _connect_side_effect() -> None:\n        s._sock = good_sock\n        s._refresh_wrappers()\n\n    with (\n        mock.patch.object(s, \"can_read\", return_value=True),\n        mock.patch.object(s, \"connect\", side_effect=_connect_side_effect),\n    ):\n        assert s.read(blocking=True, retries=1) == b\"data\"\n\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.5))\n    sock = _DummySocket()\n    s._sock = sock\n\n    with mock.patch.object(s, \"can_read\", return_value=True):\n        assert s.read(blocking=True) == b\"data\"\n\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    s._sock = _DummySocket()\n\n    with mock.patch.object(s, \"can_read\", return_value=None):\n        with pytest.raises(AppriseSocketError) as e:\n            s.read(blocking=True)\n        assert \"Socket closed\" in str(e.value)\n\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.5))\n    s._sock = _DummySocket()\n\n    with mock.patch.object(s, \"can_read\", return_value=False):\n        assert s.read(blocking=True) == b\"\"\n\n\ndef test_utils_socket_read_edge_cases():\n    \"\"\"SocketTransport() read() edge case tests.\"\"\"\n    s = SocketTransport(\"example.com\", 1)\n    s._sock = None\n    assert s.read() == b\"\"\n\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n\n    sock = _DummySocket()\n    sock.recv = mock.Mock(return_value=b\"\")  # triggers AppriseSocketError path\n    s._sock = sock\n    s._had_io = True\n\n    with (\n        mock.patch.object(s, \"_attempt_reconnect\", return_value=True),\n        pytest.raises(AppriseSocketError) as e,\n    ):\n        # retries=0 => attempts starts at 1, decremented to 0 in-loop\n        s.read(blocking=False, retries=0)\n\n    assert \"Socket read failed\" in str(e.value)\n\n\ndef test_utils_socket_attempt_reconnect_no_io() -> None:\n    \"\"\"SocketTransport() _attempt_reconnect() returns False\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n\n    # Explicitly not eligible for reconnect\n    s._had_io = False\n\n    with (\n        mock.patch.object(s, \"close\") as m_close,\n        mock.patch.object(s, \"connect\") as m_connect,\n    ):\n        assert s._attempt_reconnect(\n            retries=1,\n            action=\"read\",\n            exc=Exception(\"boom\"),\n        ) is False\n\n        # Should not attempt any reconnect work\n        m_close.assert_not_called()\n        m_connect.assert_not_called()\n\n\ndef test_utils_socket_attempt_reconnect_connect_exception() -> None:\n    \"\"\"SocketTransport() _attempt_reconnect() handles exception.\"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n\n    # Eligible for reconnect\n    s._had_io = True\n\n    with (\n        mock.patch.object(s, \"close\") as m_close,\n        mock.patch.object(\n            s, \"connect\", side_effect=Exception(\"nope\")) as m_connect,\n    ):\n        assert s._attempt_reconnect(\n            retries=1,\n            action=\"write\",\n            exc=Exception(\"boom\"),\n        ) is False\n\n        m_close.assert_called_once()\n        m_connect.assert_called_once()\n\n\ndef test_utils_socket_ssl_read_blocking() -> None:\n    \"\"\"SocketTransport() read() blocking \"\"\"\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    sock = _DummySocket()\n\n    # First recv indicates TLS wants more reads, second returns data\n    sock.recv = mock.Mock(side_effect=[ssl.SSLWantReadError(), b\"data\"])\n    s._sock = sock\n\n    # Indefinite blocking, so we must reach recv() and then loop on WANT_READ\n    with mock.patch.object(s, \"can_read\", return_value=True):\n        assert s.read(blocking=True, timeout=None) == b\"data\"\n\n    # Coverage for read blocking, WANT_WRITE\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.5))\n    sock = _DummySocket()\n\n    # Trigger the WANT_WRITE/BlockingIOError handling inside the recv loop\n    sock.recv = mock.Mock(side_effect=ssl.SSLWantWriteError())\n    s._sock = sock\n\n    # 1) initial readiness check returns True (enter recv loop)\n    # 2) inner loop calls can_read(min(...)) and gets False, returning b\"\"\n    with mock.patch.object(s, \"can_read\", side_effect=[True, False]):\n        assert s.read(blocking=True, timeout=0.5) == b\"\"\n\n    s = SocketTransport(\"example.com\", 1, timeout=(1.0, 0.5))\n    sock = _DummySocket()\n\n    # WANT_WRITE first, then a successful read\n    sock.recv = mock.Mock(side_effect=[ssl.SSLWantWriteError(), b\"data\"])\n    s._sock = sock\n\n    # can_read used twice:\n    # - readiness check (True)\n    # - busy-loop avoidance check (True)\n    with mock.patch.object(s, \"can_read\", side_effect=[True, True]):\n        assert s.read(blocking=True, timeout=0.5) == b\"data\"\n\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n    bad_sock = _DummySocket()\n\n    # In blocking read, b\"\" triggers \"Connection lost during read\"\n    bad_sock.recv = mock.Mock(return_value=b\"\")\n\n    good_sock = _DummySocket()\n    # This targets the post-reconnect immediate recv path and forces b\"\" there\n    good_sock.recv = mock.Mock(return_value=b\"\")\n\n    s._sock = bad_sock\n    s._had_io = True\n\n    def _connect_side_effect() -> None:\n        s._sock = good_sock\n        s._refresh_wrappers()\n\n    # Ensure we enter the blocking path and do the initial recv that fails,\n    # then reconnect, then attempt immediate recv on the new socket.\n    with (\n        mock.patch.object(s, \"can_read\", return_value=True),\n        mock.patch.object(s, \"connect\", side_effect=_connect_side_effect),\n    ):\n        with pytest.raises(AppriseSocketError) as e:\n            s.read(blocking=True, retries=1)\n        assert \"Connection lost during read\" in str(e.value)\n\n    s = SocketTransport(\"example.com\", 1, timeout=None)\n\n    bad_sock = _DummySocket()\n    bad_sock.recv = mock.Mock(return_value=b\"\")\n    s._sock = bad_sock\n    s._had_io = True\n\n    good_sock = _DummySocket()\n    # - First call is the post-reconnect immediate recv: it raises\n    #   BlockingIOError\n    # - Second call occurs in the next loop iteration after can_read says True\n    good_sock.recv = mock.Mock(side_effect=[BlockingIOError(), b\"data\"])\n\n    def _connect_side_effect() -> None:\n        s._sock = good_sock\n        s._refresh_wrappers()\n\n    # can_read must return True so we get to recv() on the next iteration\n    with (\n        mock.patch.object(s, \"can_read\", return_value=True),\n        mock.patch.object(s, \"connect\", side_effect=_connect_side_effect),\n    ):\n        assert s.read(blocking=True, retries=1) == b\"data\"\n"
  },
  {
    "path": "tests/var/01_test_example.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n    <!-- The above 3 meta tags *must* come first in the head; any other head content must come *after* these tags -->\n    <title>Bootstrap 101 Template</title>\n\n    <!-- Bootstrap -->\n    <link href=\"css/bootstrap.min.css\" rel=\"stylesheet\">\n\n      <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->\n      <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->\n      <!--[if lt IE 9]>\n        <script src=\"https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js\"></script>\n        <script src=\"https://oss.maxcdn.com/respond/1.4.2/respond.min.js\"></script>\n      <![endif]-->\n   </head>\n   <body>\n      <h1>My Title</h1>\n\n      <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->\n      <script src=\"https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js\"></script>\n      <!-- Include all compiled plugins (below), or include individual files as needed -->\n      <script src=\"js/bootstrap.min.js\"></script>\n\n      <h1>Heading 1</h1>\n      <p>\n        <ul>\n          <li>Bullet 1</li>\n          <li>Bullet 2</li>\n          <li>Bullet 3</li>\n        </ul>\n\n        <ol>\n          <li>Bullet 1</li>\n          <li>Bullet 2</li>\n          <li>Bullet 3</li>\n        </ol>\n      </p>\n\n      <h2>Heading 2</h2>\n      <div>A div entry</div>\n      <p>\n        <span>A div entry</span>\n      </p>\n\n      <pre><code class=\"language-python\">print('hello')</code></pre>\n\n      <h3>Heading 3</h3>\n      <h4>Heading 4</h4>\n      <h5>Heading 5</h5>\n      <h6>Heading 6</h6>\n\n      <p>\n      A set of text <br/>Another line after the set of text\n      <hr/>\n      More text\n      </p>\n      <form>\n        <label>label</label>\n        <input/>\n        <select/>\n      </form>\n   </body>\n</html>\n"
  },
  {
    "path": "tests/var/fcm/service_account-bad-type.json",
    "content": "{\n\t\"type\": \"bad_type\",\n\t\"project_id\": \"mock-project-id\",\n\t\"private_key_id\": \"mock-key-id-1\",\n        \"private_key\": \"-----BEGIN RSA PRIVATE KEY-----\\nMIIEpAIBAAKCAQEAwJENcRev+eXZKvhhWLiV3Lz2MvO+naQRHo59g3vaNQnbgyduN/L4krlr\\nJ5c6FiikXdtJNb/QrsAHSyJWCu8j3T9CruiwbidGAk2W0RuViTVspjHUTsIHExx9euWM0Uom\\nGvYkoqXahdhPL/zViVSJt+Rt8bHLsMvpb8RquTIb9iKY3SMV2tCofNmyCSgVbghq/y7lKORt\\nV/IRguWs6R22fbkb0r2MCYoNAbZ9dqnbRIFNZBC7itYtUoTEresRWcyFMh0zfAIJycWOJlVL\\nDLqkY2SmIx8u7fuysCg1wcoSZoStuDq02nZEMw1dx8HGzE0hynpHlloRLByuIuOAfMCCYwID\\nAQABAoIBADFtihu7TspAO0wSUTpqttzgC/nsIsNn95T2UjVLtyjiDNxPZLUrwq42tdCFur0x\\nVW9Z+CK5x6DzXWvltlw8IeKKeF1ZEOBVaFzy+YFXKTz835SROcO1fgdjyrme7lRSShGlmKW/\\nGKY+baUNquoDLw5qreXaE0SgMp0jt5ktyYuVxvhLDeV4omw2u6waoGkifsGm8lYivg5l3VR7\\nw2IVOvYZTt4BuSYVwOM+qjwaS1vtL7gv0SUjrj85Ja6zERRdFiITDhZw6nsvacr9/+/aut9E\\naL/koSSb62g5fntQMEwoT4hRnjPnAedmorM9Rhddh2TB3ZKTBbMN1tUk3fJxOuECgYEA+z6l\\neSaAcZ3qvwpntcXSpwwJ0SSmzLTH2RJNf+Ld3eBHiSvLTG53dWB7lJtF4R1KcIwf+KGcOFJv\\nsnepzcZBylRvT8RrAAkV0s9OiVm1lXZyaepbLg4GGFJBPi8A6VIAj7zYknToRApdW0s1x/XX\\nChewfJDckqsevTMovdbg8YkCgYEAxDYX+3mfvv/opo6HNNY3SfVunM+4vVJL+n8gWZ2w9kz3\\nQ9Ub9YbRmI7iQaiVkO5xNuoG1n9bM+3Mnm84aQ1YeNT01YqeyQsipP5Wi+um0PzYTaBw9RO+\\n8Gh6992OwlJiRtFk5WjalNWOxY4MU0ImnJwIfKQlUODvLmcixm68NYsCgYEAuAqI3jkk55Vd\\nKvotREsX5wP7gPePM+7NYiZ1HNQL4Ab1f/bTojZdTV8Sx6YCR0fUiqMqnE+OBvfkGGBtw22S\\nLesx6sWf99Ov58+x4Q0U5dpxL0Lb7d2Z+2Dtp+Z4jXFjNeeI4ae/qG/LOR/b0pE0J5F415ap\\n7Mpq5v89vepUtrkCgYAjMXytu4v+q1Ikhc4UmRPDrUUQ1WVSd+9u19yKlnFGTFnRjej86hiw\\nH3jPxBhHra0a53EgiilmsBGSnWpl1WH4EmJz5vBCKUAmjgQiBrueIqv9iHiaTNdjsanUyaWw\\njyxXfXl2eI80QPXh02+8g1H/pzESgjK7Rg1AqnkfVH9nrwKBgQDJVxKBPTw9pigYMVt9iHrR\\niCl9zQVjRMbWiPOc0J56+/5FZYm/AOGl9rfhQ9vGxXZYZiOP5FsNkwt05Y1UoAAH4B4VQwbL\\nqod71qOcI0ywgZiIR87CYw40gzRfjWnN+YEEW1qfyoNLilEwJB8iB/T+ZePHGmJ4MmQ/cTn9\\nxpdLXA==\\n-----END RSA PRIVATE KEY-----\",\n\t\"client_email\": \"mock-email@mock-project.iam.gserviceaccount.com\",\n\t\"client_id\": \"1234567890\",\n\t\"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n\t\"token_uri\": \"https://accounts.google.com/o/oauth2/token\",\n\t\"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n\t\"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/mock-project-id.iam.gserviceaccount.com\"\n}\n"
  },
  {
    "path": "tests/var/fcm/service_account.json",
    "content": "{\n\t\"type\": \"service_account\",\n\t\"project_id\": \"mock-project-id\",\n\t\"private_key_id\": \"mock-key-id-1\",\n        \"private_key\": \"-----BEGIN RSA PRIVATE KEY-----\\nMIIEpAIBAAKCAQEAwJENcRev+eXZKvhhWLiV3Lz2MvO+naQRHo59g3vaNQnbgyduN/L4krlr\\nJ5c6FiikXdtJNb/QrsAHSyJWCu8j3T9CruiwbidGAk2W0RuViTVspjHUTsIHExx9euWM0Uom\\nGvYkoqXahdhPL/zViVSJt+Rt8bHLsMvpb8RquTIb9iKY3SMV2tCofNmyCSgVbghq/y7lKORt\\nV/IRguWs6R22fbkb0r2MCYoNAbZ9dqnbRIFNZBC7itYtUoTEresRWcyFMh0zfAIJycWOJlVL\\nDLqkY2SmIx8u7fuysCg1wcoSZoStuDq02nZEMw1dx8HGzE0hynpHlloRLByuIuOAfMCCYwID\\nAQABAoIBADFtihu7TspAO0wSUTpqttzgC/nsIsNn95T2UjVLtyjiDNxPZLUrwq42tdCFur0x\\nVW9Z+CK5x6DzXWvltlw8IeKKeF1ZEOBVaFzy+YFXKTz835SROcO1fgdjyrme7lRSShGlmKW/\\nGKY+baUNquoDLw5qreXaE0SgMp0jt5ktyYuVxvhLDeV4omw2u6waoGkifsGm8lYivg5l3VR7\\nw2IVOvYZTt4BuSYVwOM+qjwaS1vtL7gv0SUjrj85Ja6zERRdFiITDhZw6nsvacr9/+/aut9E\\naL/koSSb62g5fntQMEwoT4hRnjPnAedmorM9Rhddh2TB3ZKTBbMN1tUk3fJxOuECgYEA+z6l\\neSaAcZ3qvwpntcXSpwwJ0SSmzLTH2RJNf+Ld3eBHiSvLTG53dWB7lJtF4R1KcIwf+KGcOFJv\\nsnepzcZBylRvT8RrAAkV0s9OiVm1lXZyaepbLg4GGFJBPi8A6VIAj7zYknToRApdW0s1x/XX\\nChewfJDckqsevTMovdbg8YkCgYEAxDYX+3mfvv/opo6HNNY3SfVunM+4vVJL+n8gWZ2w9kz3\\nQ9Ub9YbRmI7iQaiVkO5xNuoG1n9bM+3Mnm84aQ1YeNT01YqeyQsipP5Wi+um0PzYTaBw9RO+\\n8Gh6992OwlJiRtFk5WjalNWOxY4MU0ImnJwIfKQlUODvLmcixm68NYsCgYEAuAqI3jkk55Vd\\nKvotREsX5wP7gPePM+7NYiZ1HNQL4Ab1f/bTojZdTV8Sx6YCR0fUiqMqnE+OBvfkGGBtw22S\\nLesx6sWf99Ov58+x4Q0U5dpxL0Lb7d2Z+2Dtp+Z4jXFjNeeI4ae/qG/LOR/b0pE0J5F415ap\\n7Mpq5v89vepUtrkCgYAjMXytu4v+q1Ikhc4UmRPDrUUQ1WVSd+9u19yKlnFGTFnRjej86hiw\\nH3jPxBhHra0a53EgiilmsBGSnWpl1WH4EmJz5vBCKUAmjgQiBrueIqv9iHiaTNdjsanUyaWw\\njyxXfXl2eI80QPXh02+8g1H/pzESgjK7Rg1AqnkfVH9nrwKBgQDJVxKBPTw9pigYMVt9iHrR\\niCl9zQVjRMbWiPOc0J56+/5FZYm/AOGl9rfhQ9vGxXZYZiOP5FsNkwt05Y1UoAAH4B4VQwbL\\nqod71qOcI0ywgZiIR87CYw40gzRfjWnN+YEEW1qfyoNLilEwJB8iB/T+ZePHGmJ4MmQ/cTn9\\nxpdLXA==\\n-----END RSA PRIVATE KEY-----\",\n\t\"client_email\": \"mock-email@mock-project.iam.gserviceaccount.com\",\n\t\"client_id\": \"1234567890\",\n\t\"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n\t\"token_uri\": \"https://accounts.google.com/o/oauth2/token\",\n\t\"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n\t\"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/mock-project-id.iam.gserviceaccount.com\"\n}\n"
  },
  {
    "path": "tests/var/mime.types",
    "content": "# This is a comment. I love comments.\t\t    -*- indent-tabs-mode: t -*-\n\n# This file controls what Internet media types are sent to the client for\n# given file extension(s).  Sending the correct media type to the client\n# is important so they know how to handle the content of the file.\n# Extra types can either be added here or by using an AddType directive\n# in your config files. For more information about Internet media types,\n# please read RFC 2045, 2046, 2047, 2048, and 2077.  The Internet media type\n# registry is at <http://www.iana.org/assignments/media-types/>.\n\n# Even types without defined extensions are listed here for completeness.\n# Compound extensions like cwl.json are allowed.\n\n# IANA types\n\n# MIME type\t\t\t\t\tExtensions\napplication/1d-interleaved-parityfec\napplication/3gpdash-qoe-report+xml\napplication/3gppHal+json\napplication/3gppHalForms+json\napplication/3gpp-ims+xml\napplication/A2L\t\t\t\t\ta2l\napplication/ace+cbor\napplication/ace+json\napplication/activemessage\napplication/activity+json\napplication/aif+cbor\napplication/aif+json\napplication/alto-cdni+json\napplication/alto-cdnifilter+json\napplication/alto-costmap+json\napplication/alto-costmapfilter+json\napplication/alto-directory+json\napplication/alto-endpointcost+json\napplication/alto-endpointcostparams+json\napplication/alto-endpointprop+json\napplication/alto-endpointpropparams+json\napplication/alto-error+json\napplication/alto-networkmap+json\napplication/alto-propmap+json\napplication/alto-propmapparams+json\napplication/alto-updatestreamcontrol+json\napplication/alto-updatestreamparams+json\napplication/alto-networkmapfilter+json\napplication/AML\t\t\t\t\taml\napplication/andrew-inset\t\t\tez\napplication/applefile\napplication/at+jwt\napplication/ATF\t\t\t\t\tatf\napplication/ATFX\t\t\t\tatfx\napplication/ATXML\t\t\t\tatxml\napplication/atom+xml\t\t\t\tatom\napplication/atomcat+xml\t\t\t\tatomcat\napplication/atomdeleted+xml\t\t\tatomdeleted\napplication/atomicmail\napplication/atomsvc+xml\t\t\t\tatomsvc\napplication/atsc-dwd+xml\t\t\tdwd\napplication/atsc-dynamic-event-message\napplication/atsc-held+xml\t\t\theld\napplication/atsc-rdt+json\napplication/atsc-rsat+xml\t\t\trsat\napplication/auth-policy+xml\t\t\tapxml\napplication/automationml-aml+xml\napplication/automationml-amlx+zip   amlx\napplication/bacnet-xdd+zip\t\t\txdd\napplication/batch-SMTP\napplication/beep+xml\napplication/calendar+json\napplication/calendar+xml\t\t\txcs\napplication/call-completion\napplication/CALS-1840\napplication/captive+json\napplication/cap+xml\napplication/cbor\t\t\t\tcbor\napplication/cbor-seq\napplication/cccex\t\t\t\tc3ex\napplication/ccmp+xml\t\t\t\tccmp\napplication/ccxml+xml\t\t\t\tccxml\napplication/cda+xml\napplication/CDFX+XML\t\t\t\tcdfx\napplication/cdmi-capability\t\t\tcdmia\napplication/cdmi-container\t\t\tcdmic\napplication/cdmi-domain\t\t\t\tcdmid\napplication/cdmi-object\t\t\t\tcdmio\napplication/cdmi-queue\t\t\t\tcdmiq\napplication/cdni\napplication/CEA\t\t\t\t\tcea\napplication/cea-2018+xml\napplication/cellml+xml\t\t\t\tcellml cml\napplication/cfw\napplication/city+json\napplication/clr                     1clr\napplication/clue_info+xml\t\t\tclue\napplication/clue+xml\napplication/cms\t\t\t\t\tcmsc\napplication/cnrp+xml\napplication/coap-group+json\napplication/coap-payload\napplication/commonground\napplication/concise-problem-details+cbor\napplication/conference-info+xml\napplication/cpl+xml\t\t\t\tcpl\napplication/cose\napplication/cose-key\napplication/cose-key-set\napplication/cose-x509\napplication/csrattrs\t\t\t\tcsrattrs\napplication/csta+xml\napplication/CSTAdata+xml\napplication/csvm+json\napplication/cwl                     cwl\napplication/cwl+json                cwl.json\napplication/cwt\napplication/cybercash\napplication/dash+xml\t\t\t\tmpd\napplication/dash-patch+xml\napplication/dashdelta\t\t\t\tmpdd\napplication/davmount+xml\t\t\tdavmount\napplication/dca-rft\napplication/DCD\t\t\t\t\tdcd\napplication/dec-dx\napplication/dialog-info+xml\napplication/dicom\t\t\t\tdcm\napplication/dicom+json\napplication/dicom+xml\napplication/DII\t\t\t\t\tdii\napplication/DIT\t\t\t\t\tdit\napplication/dns\napplication/dns+json\napplication/dns-message\napplication/dots+cbor\napplication/dpop+jwt\napplication/dskpp+xml\t\t\t\txmls\napplication/dssc+der\t\t\t\tdssc\napplication/dssc+xml\t\t\t\txdssc\napplication/dvcs\t\t\t\tdvc\napplication/ecmascript\t\t\t\tes\napplication/EDI-consent\napplication/EDI-X12\napplication/EDIFACT\napplication/efi\t\t\t\t\tefi\napplication/elm+json\napplication/elm+xml\napplication/EmergencyCallData.cap+xml\napplication/EmergencyCallData.Comment+xml\napplication/EmergencyCallData.Control+xml\napplication/EmergencyCallData.DeviceInfo+xml\napplication/EmergencyCallData.eCall.MSD\napplication/EmergencyCallData.LegacyESN+json\napplication/EmergencyCallData.ProviderInfo+xml\napplication/EmergencyCallData.ServiceInfo+xml\napplication/EmergencyCallData.SubscriberInfo+xml\napplication/EmergencyCallData.VEDS+xml\napplication/emma+xml\t\t\t\temma\napplication/emotionml+xml\t\t\temotionml\napplication/encaprtp\napplication/epp+xml\napplication/epub+zip\t\t\t\tepub\napplication/eshop\napplication/exi\t\t\t\t\texi\napplication/expect-ct-report+json\napplication/express             exp\napplication/fastinfoset\t\t\t\tfinf\napplication/fastsoap\napplication/fdf\napplication/fdt+xml\t\t\t\tfdt\napplication/fhir+json\napplication/fhir+xml\n# fits, fit, fts: image/fits\napplication/fits\napplication/flexfec\n# application/font-sfnt deprecated in favor of font/sfnt\napplication/font-tdpfr\t\t\t\tpfr\n# application/font-woff deprecated in favor of font/woff\napplication/framework-attributes+xml\napplication/geo+json\t\t\t\tgeojson\napplication/geo+json-seq\napplication/geopackage+sqlite3\t\t\tgpkg\napplication/geoxacml+xml\napplication/gltf-buffer\t\t\t\tglbin glbuf\napplication/gml+xml\t\t\t\tgml\napplication/gzip\t\t\t\tgz tgz\napplication/H224\napplication/held+xml\napplication/hl7v2+xml\napplication/http\napplication/hyperstudio\t\t\t\tstk\napplication/ibe-key-request+xml\napplication/ibe-pkg-reply+xml\napplication/ibe-pp-data\napplication/iges\napplication/im-iscomposing+xml\napplication/index\napplication/index.cmd\napplication/index.obj\napplication/index.response\napplication/index.vnd\napplication/inkml+xml\t\t\t\tink inkml\napplication/IOTP\napplication/ipfix\t\t\t\tipfix\napplication/ipp\napplication/ISUP\napplication/its+xml\t\t\t\tits\napplication/java-archive        jar\n# application/javascript obsoleted by text/javascript\napplication/jf2feed+json\napplication/jose\napplication/jose+json\napplication/jrd+json\t\t\t\tjrd\napplication/jscalendar+json\napplication/json\t\t\t\tjson\napplication/json-patch+json\t\t\tjson-patch\napplication/json-seq\napplication/jwk+json\napplication/jwk-set+json\napplication/jwt\napplication/kpml-request+xml\napplication/kpml-response+xml\napplication/ld+json\t\t\t\tjsonld\napplication/lgr+xml\t\t\t\tlgr\napplication/link-format\t\t\t\twlnk\napplication/linkset\napplication/linkset+json\napplication/load-control+xml\napplication/logout+jwt\napplication/lost+xml\t\t\t\tlostxml\napplication/lostsync+xml\t\t\tlostsyncxml\napplication/lpf+zip\t\t\t\tlpf\napplication/LXF\t\t\t\t\tlxf\napplication/mac-binhex40\t\t\thqx\napplication/macwriteii\napplication/mads+xml\t\t\t\tmads\napplication/manifest+json       webmanifest\napplication/marc\t\t\t\tmrc\napplication/marcxml+xml\t\t\t\tmrcx\napplication/mathematica\t\t\t\tnb ma mb\napplication/mathml-content+xml\napplication/mathml-presentation+xml\napplication/mathml+xml\t\t\t\tmml\napplication/mbms-associated-procedure-description+xml\napplication/mbms-deregister+xml\napplication/mbms-envelope+xml\napplication/mbms-msk-response+xml\napplication/mbms-msk+xml\napplication/mbms-protection-description+xml\napplication/mbms-reception-report+xml\napplication/mbms-register-response+xml\napplication/mbms-register+xml\napplication/mbms-schedule+xml\napplication/mbms-user-service-description+xml\napplication/mbox\t\t\t\tmbox\napplication/media_control+xml\n# mpf: text/vnd.ms-mediapackage\napplication/media-policy-dataset+xml\napplication/mediaservercontrol+xml\napplication/merge-patch+json\napplication/metalink4+xml\t\t\tmeta4\napplication/mets+xml\t\t\t\tmets\napplication/MF4\t\t\t\t\tmf4\napplication/mikey\napplication/mipc                h5\napplication/missing-blocks+cbor-seq\napplication/mmt-aei+xml\t\t\t\tmaei\napplication/mmt-usd+xml\t\t\t\tmusd\napplication/mods+xml\t\t\t\tmods\napplication/moss-keys\napplication/moss-signature\napplication/mosskey-data\napplication/mosskey-request\napplication/mp21\t\t\t\tm21 mp21\n# mp4, mpg4: video/mp4, see RFC 4337\napplication/mp4\napplication/mpeg4-generic\napplication/mpeg4-iod\napplication/mpeg4-iod-xmt\n# xdf: application/xcap-diff+xml\napplication/mrb-consumer+xml\napplication/mrb-publish+xml\napplication/msc-ivr+xml\napplication/msc-mixer+xml\napplication/msword\t\t\t\tdoc\napplication/mud+json\napplication/multipart-core\napplication/mxf\t\t\t\t\tmxf\napplication/n-quads\t\t\t\tnq\napplication/n-triples\t\t\t\tnt\napplication/nasdata\napplication/news-checkgroups\napplication/news-groupinfo\napplication/news-transmission\napplication/nlsml+xml\napplication/node\napplication/nss\napplication/oauth-authz-req+jwt\napplication/oblivious-dns-message\napplication/ocsp-request\t\t\torq\napplication/ocsp-response\t\t\tors\napplication/octet-stream\t\tbin lha lzh exe class so dll img iso\napplication/ODA\t\t\t\t\toda\napplication/odm+xml\napplication/ODX\t\t\t\t\todx\napplication/oebps-package+xml\t\t\topf\napplication/ogg\t\t\t\t\togx\napplication/ohttp-keys\napplication/opc-nodeset+xml     \napplication/oscore\napplication/oxps\t\t\t\toxps\napplication/p21                 p21 stp step stpnc 210 ifc\napplication/p21+zip             stpz\napplication/p2p-overlay+xml\t\t\trelo\napplication/parityfec\napplication/passport\n# xer: application/xcap-error+xml\napplication/patch-ops-error+xml\napplication/pdf\t\t\t\t\tpdf\napplication/PDX\t\t\t\t\tpdx\napplication/pem-certificate-chain\t\tpem\napplication/pgp-encrypted\t\t\tpgp\napplication/pgp-keys\napplication/pgp-signature\t\t\tsig\napplication/pidf-diff+xml\napplication/pidf+xml\napplication/pkcs10\t\t\t\tp10\napplication/pkcs12\t\t\t\tp12 pfx\napplication/pkcs7-mime\t\t\t\tp7m p7c\napplication/pkcs7-signature\t\t\tp7s\napplication/pkcs8\t\t\t\tp8\napplication/pkcs8-encrypted\t\t\tp8e\n# ac: application/vnd.nokia.n-gage.ac+xml\napplication/pkix-attr-cert\napplication/pkix-cert\t\t\t\tcer\napplication/pkix-crl\t\t\t\tcrl\napplication/pkix-pkipath\t\t\tpkipath\napplication/pkixcmp\t\t\t\tpki\napplication/pls+xml\t\t\t\tpls\napplication/poc-settings+xml\napplication/postscript\t\t\t\tps eps ai\napplication/ppsp-tracker+json\napplication/problem+json\napplication/problem+xml\napplication/provenance+xml\t\t\tprovx\napplication/prs.alvestrand.titrax-sheet\napplication/prs.cww\t\t\t\tcw cww\napplication/prs.cyn\napplication/prs.hpub+zip\t\t\thpub\napplication/prs.implied-document+xml\napplication/prs.implied-executable\napplication/prs.implied-structure\napplication/prs.nprend\t\t\t\trnd rct\napplication/prs.plucker\napplication/prs.rdf-xml-crypt\t\t\trdf-crypt\napplication/prs.xsf+xml\t\t\t\txsf\napplication/pskc+xml\t\t\t\tpskcxml\napplication/pvd+json\napplication/QSIG\napplication/raptorfec\napplication/rdap+json\napplication/rdf+xml\t\t\t\trdf\napplication/route-apd+xml\t\t\trapd\napplication/route-s-tsid+xml\t\t\tsls\napplication/route-usd+xml\t\t\trusd\napplication/reginfo+xml\t\t\t\trif\napplication/relax-ng-compact-syntax\t\trnc\napplication/remote-printing\napplication/reputon+json\napplication/resource-lists-diff+xml\t\trld\napplication/resource-lists+xml\t\t\trl\napplication/rfc+xml\t\t\t\trfcxml\napplication/riscos\napplication/rlmi+xml\napplication/rls-services+xml\t\t\trs\napplication/rpki-checklist\napplication/rpki-ghostbusters\t\t\tgbr\napplication/rpki-manifest\t\t\tmft\napplication/rpki-publication\napplication/rpki-roa\t\t\t\troa\napplication/rpki-updown\napplication/rtf\t\t\t\t\trtf\napplication/rtploopback\napplication/rtx\napplication/samlassertion+xml\napplication/samlmetadata+xml\napplication/sarif-external-properties+json sarif-external-properties sarif-external-properties.json\napplication/sarif+json          sarif sarif.json\napplication/sbe\napplication/sbml+xml\napplication/scaip+xml\n# scm: application/vnd.lotus-screencam\napplication/scim+json\t\t\t\tscim\napplication/scvp-cv-request\t\t\tscq\napplication/scvp-cv-response\t\t\tscs\napplication/scvp-vp-request\t\t\tspq\napplication/scvp-vp-response\t\t\tspp\napplication/sdp\t\t\t\t\tsdp\napplication/secevent+jwt\napplication/senml-etch+cbor\t\t\tsenml-etchc\napplication/senml-etch+json\t\t\tsenml-etchj\napplication/senml+cbor\t\t\t\tsenmlc\napplication/senml+json\t\t\t\tsenml\napplication/senml+xml\t\t\t\tsenmlx\napplication/senml-exi\t\t\t\tsenmle\napplication/sensml+cbor\t\t\t\tsensmlc\napplication/sensml+json\t\t\t\tsensml\napplication/sensml+xml\t\t\t\tsensmlx\napplication/sensml-exi\t\t\t\tsensmle\napplication/sep+xml\napplication/sep-exi\napplication/session-info\napplication/set-payment\napplication/set-payment-initiation\napplication/set-registration\napplication/set-registration-initiation\napplication/SGML\napplication/sgml-open-catalog\t\t\tsoc\napplication/shf+xml\t\t\t\tshf\napplication/sieve\t\t\t\tsiv sieve\napplication/simple-filter+xml\t\t\tcl\napplication/simple-message-summary\napplication/simpleSymbolContainer\n# h5: application/mipc\napplication/sipc\napplication/slate\n# application/smil obsoleted by application/smil+xml\napplication/smil+xml\t\t\t\tsmil smi sml\napplication/smpte336m\napplication/soap+fastinfoset\napplication/soap+xml\napplication/sparql-query\t\t\trq\napplication/spdx+json                   spdx.json\napplication/sparql-results+xml\t\t\tsrx\napplication/spirits-event+xml\napplication/sql\t\t\t\t\tsql\napplication/srgs\t\t\t\tgram\napplication/srgs+xml\t\t\t\tgrxml\napplication/sru+xml\t\t\t\tsru\napplication/ssml+xml\t\t\t\tssml\napplication/stix+json\t\t\t\tstix\napplication/swid+cbor               coswid\napplication/swid+xml\t\t\t\tswidtag\napplication/tamp-apex-update\t\t\ttau\napplication/tamp-apex-update-confirm\t\tauc\napplication/tamp-community-update\t\ttcu\napplication/tamp-community-update-confirm\tcuc\napplication/taxii+json\napplication/td+json\t\t\t\tjsontd\napplication/tamp-error\t\t\t\tter\napplication/tamp-sequence-adjust\t\ttsa\napplication/tamp-sequence-adjust-confirm\tsac\n# tsq: application/timestamp-query\napplication/tamp-status-query\n# tsr: application/timestamp-reply\napplication/tamp-status-response\napplication/tamp-update\t\t\t\ttur\napplication/tamp-update-confirm\t\t\ttuc\napplication/tei+xml\t\t\t\ttei teiCorpus odd\napplication/TETRA_ISI\napplication/thraud+xml\t\t\t\ttfi\napplication/timestamp-query\t\t\ttsq\napplication/timestamp-reply\t\t\ttsr\napplication/timestamped-data\t\t\ttsd\napplication/tlsrpt+gzip\napplication/tlsrpt+json\napplication/tm+json                 jsontm tm.json tm.jsonld\napplication/tnauthlist\napplication/token-introspection+jwt\napplication/trickle-ice-sdpfrag\napplication/trig\t\t\t\ttrig\napplication/ttml+xml\t\t\t\tttml\napplication/tve-trigger\napplication/tzif\napplication/tzif-leap\napplication/ulpfec\napplication/urc-grpsheet+xml\t\t\tgsheet\napplication/urc-ressheet+xml\t\t\trsheet\napplication/urc-targetdesc+xml\t\t\ttd\napplication/urc-uisocketdesc+xml\t\tuis\napplication/vcard+json\napplication/vcard+xml\napplication/vemmi\napplication/vnd.1000minds.decision-model+xml\t1km\napplication/vnd.1ob                                 ob\napplication/vnd.3gpp.5gnas              \napplication/vnd.3gpp.access-transfer-events+xml\napplication/vnd.3gpp.bsf+xml\napplication/vnd.3gpp.crs+xml\napplication/vnd.3gpp.current-location-discovery+xml\napplication/vnd.3gpp.GMOP+xml\napplication/vnd.3gpp.gtpc\napplication/vnd.3gpp.interworking-data\napplication/vnd.3gpp.lpp\napplication/vnd.3gpp.mc-signalling-ear\napplication/vnd.3gpp.mcdata-affiliation-command+xml\napplication/vnd.3gpp.mcdata-info+xml\napplication/vnd.3gpp.mcdata-msgstore-ctrl-request+xml\napplication/vnd.3gpp.mcdata-payload\napplication/vnd.3gpp.mcdata-regroup+xml\napplication/vnd.3gpp.mcdata-service-config+xml\napplication/vnd.3gpp.mcdata-signalling\napplication/vnd.3gpp.mcdata-ue-config+xml\napplication/vnd.3gpp.mcdata-user-profile+xml\napplication/vnd.3gpp.mcptt-affiliation-command+xml\napplication/vnd.3gpp.mcptt-floor-request+xml\napplication/vnd.3gpp.mcptt-info+xml\napplication/vnd.3gpp.mcptt-location-info+xml\napplication/vnd.3gpp.mcptt-mbms-usage-info+xml\napplication/vnd.3gpp.mcptt-regroup+xml\napplication/vnd.3gpp.mcptt-service-config+xml\napplication/vnd.3gpp.mcptt-signed+xml\napplication/vnd.3gpp.mcptt-ue-config+xml\napplication/vnd.3gpp.mcptt-ue-init-config+xml\napplication/vnd.3gpp.mcptt-user-profile+xml\napplication/vnd.3gpp.mcvideo-affiliation-command+xml\napplication/vnd.3gpp.mcvideo-info+xml\napplication/vnd.3gpp.mcvideo-location-info+xml\napplication/vnd.3gpp.mcvideo-mbms-usage-info+xml\napplication/vnd.3gpp.mcvideo-regroup+xml\napplication/vnd.3gpp.mcvideo-service-config+xml\napplication/vnd.3gpp.mcvideo-transmission-request+xml\napplication/vnd.3gpp.mcvideo-ue-config+xml\napplication/vnd.3gpp.mcvideo-user-profile+xml\napplication/vnd.3gpp.mid-call+xml\napplication/vnd.3gpp.ngap\napplication/vnd.3gpp.pfcp\napplication/vnd.3gpp.pic-bw-large\t\tplb\napplication/vnd.3gpp.pic-bw-small\t\tpsb\napplication/vnd.3gpp.pic-bw-var\t\t\tpvb\napplication/vnd.3gpp-prose-pc3a+xml\napplication/vnd.3gpp-prose-pc3ach+xml\napplication/vnd.3gpp-prose+xml\napplication/vnd.3gpp.s1ap\napplication/vnd.3gpp.seal-group-doc+xml\napplication/vnd.3gpp.seal-info+xml\napplication/vnd.3gpp.seal-location-info+xml\napplication/vnd.3gpp.seal-mbms-usage-info+xml\napplication/vnd.3gpp.seal-network-QoS-management-info+xml\napplication/vnd.3gpp.seal-ue-config-info+xml\napplication/vnd.3gpp.seal-unicast-info+xml\napplication/vnd.3gpp.seal-user-profile-info+xml\napplication/vnd.3gpp-prose-pc3ch+xml\napplication/vnd.3gpp-prose-pc8+xml\n# sms: application/vnd.3gpp2.sms\napplication/vnd.3gpp.sms\napplication/vnd.3gpp.sms+xml\napplication/vnd.3gpp.srvcc-ext+xml\napplication/vnd.3gpp.SRVCC-info+xml\napplication/vnd.3gpp.state-and-event-info+xml\napplication/vnd.3gpp.ussd+xml\napplication/vnd.3gpp.vae-info+xml\napplication/vnd.3gpp-v2x-local-service-information\napplication/vnd.3gpp2.bcmcsinfo+xml\napplication/vnd.3gpp2.sms\t\t\tsms\napplication/vnd.3gpp2.tcap\t\t\ttcap\napplication/vnd.3gpp.v2x\napplication/vnd.3lightssoftware.imagescal\timgcal\napplication/vnd.3M.Post-it-Notes\t\tpwn\napplication/vnd.accpac.simply.aso\t\taso\napplication/vnd.accpac.simply.imp\t\timp\napplication/vnd.acm.addressxfer+json\napplication/vnd.acucobol\t\t\tacu\napplication/vnd.acucorp\t\t\t\tatc acutc\napplication/vnd.adobe.flash.movie\t\tswf\napplication/vnd.adobe.formscentral.fcdt\t\tfcdt\napplication/vnd.adobe.fxp\t\t\tfxp fxpl\napplication/vnd.adobe.partial-upload\napplication/vnd.adobe.xdp+xml\t\t\txdp\napplication/vnd.aether.imp\napplication/vnd.afpc.afplinedata\napplication/vnd.afpc.afplinedata-pagedef\napplication/vnd.afpc.cmoca-cmresource\napplication/vnd.afpc.foca-charset\napplication/vnd.afpc.foca-codedfont\napplication/vnd.afpc.foca-codepage\napplication/vnd.afpc.modca\t\t\tlist3820 listafp afp pseg3820\napplication/vnd.afpc.modca-cmtable\napplication/vnd.afpc.modca-formdef\napplication/vnd.afpc.modca-mediummap\napplication/vnd.afpc.modca-objectcontainer\napplication/vnd.afpc.modca-overlay\t\tovl\napplication/vnd.afpc.modca-pagesegment\t\tpsg\napplication/vnd.age                 age\napplication/vnd.ah-barcode\napplication/vnd.ahead.space\t\t\tahead\napplication/vnd.airzip.filesecure.azf\t\tazf\napplication/vnd.airzip.filesecure.azs\t\tazs\napplication/vnd.amadeus+json\napplication/vnd.amazon.mobi8-ebook\t\tazw3\napplication/vnd.americandynamics.acc\t\tacc\napplication/vnd.amiga.ami\t\t\tami\napplication/vnd.amundsen.maze+xml\napplication/vnd.android.ota\t\t\tota\napplication/vnd.anki\t\t\t\tapkg\napplication/vnd.anser-web-certificate-issue-initiation\tcii\n# Not in IANA listing, but is on FTP site?\napplication/vnd.anser-web-funds-transfer-initiation\tfti\n# atx: audio/ATRAC-X\napplication/vnd.antix.game-component\napplication/vnd.apache.arrow.file   arrow\napplication/vnd.apache.arrow.stream     arrows\napplication/vnd.apache.thrift.binary\napplication/vnd.apache.thrift.compact\napplication/vnd.apache.thrift.json\napplication/vnd.apexlang        apexland apex axdl\napplication/vnd.api+json\napplication/vnd.aplextor.warrp+json\napplication/vnd.apothekende.reservation+json\napplication/vnd.apple.installer+xml\t\tdist distz pkg mpkg\napplication/vnd.apple.keynote\t\t\tkeynote\n# m3u: audio/x-mpegurl for now\napplication/vnd.apple.mpegurl\t\t\tm3u8\napplication/vnd.apple.numbers\t\t\tnumbers\napplication/vnd.apple.pages\t\t\tpages\n# application/vnd.arastra.swi obsoleted by application/vnd.aristanetworks.swi\napplication/vnd.aristanetworks.swi\t\tswi\napplication/vnd.artisan+json\t\t\tartisan\napplication/vnd.artsquare\napplication/vnd.astraea-software.iota\t\tiota\napplication/vnd.audiograph\t\t\taep\napplication/vnd.autopackage\t\t\tpackage\napplication/vnd.avalon+json\napplication/vnd.avistar+xml\napplication/vnd.balsamiq.bmml+xml\t\tbmml\napplication/vnd.banana-accounting\t\tac2\napplication/vnd.bbf.usp.error\napplication/vnd.balsamiq.bmpr\t\t\tbmpr\napplication/vnd.bbf.usp.msg\napplication/vnd.bbf.usp.msg+json\napplication/vnd.bekitzur-stech+json\napplication/vnd.belightsoft.lhzd+zip    lhzd\napplication/vnd.belightsoft.lhzl+zip    lhzl\napplication/vnd.bint.med-content\napplication/vnd.biopax.rdf+xml\napplication/vnd.blink-idb-value-wrapper\napplication/vnd.blueice.multipass\t\tmpm\napplication/vnd.bluetooth.ep.oob\t\tep\napplication/vnd.bluetooth.le.oob\t\tle\napplication/vnd.bmi\t\t\t\tbmi\napplication/vnd.bpf\napplication/vnd.bpf3\napplication/vnd.businessobjects\t\t\trep\napplication/vnd.byu.uapi+json\napplication/vnd.cab-jscript\napplication/vnd.canon-cpdl\napplication/vnd.canon-lips\napplication/vnd.capasystems-pg+json\napplication/vnd.cendio.thinlinc.clientconf\ttlclient\napplication/vnd.century-systems.tcp_stream\napplication/vnd.chemdraw+xml\t\t\tcdxml\napplication/vnd.chess-pgn\t\t\tpgn\napplication/vnd.chipnuts.karaoke-mmd\t\tmmd\napplication/vnd.ciedi\napplication/vnd.cinderella\t\t\tcdy\napplication/vnd.cirpack.isdn-ext\napplication/vnd.citationstyles.style+xml\tcsl\napplication/vnd.claymore\t\t\tcla\napplication/vnd.cloanto.rp9\t\t\trp9\napplication/vnd.clonk.c4group\t\t\tc4g c4d c4f c4p c4u\napplication/vnd.cluetrust.cartomobile-config\tc11amc\napplication/vnd.cluetrust.cartomobile-config-pkg\tc11amz\napplication/vnd.cncf.helm.chart.content.v1.tar+gzip\napplication/vnd.cncf.helm.chart.provenance.v1.prov\napplication/vnd.cncf.helm.config.v1+json\napplication/vnd.coffeescript\t\t\tcoffee\napplication/vnd.collabio.xodocuments.document\txodt\napplication/vnd.collabio.xodocuments.document-template\txott\napplication/vnd.collabio.xodocuments.presentation\txodp\napplication/vnd.collabio.xodocuments.presentation-template\txotp\napplication/vnd.collabio.xodocuments.spreadsheet\txods\napplication/vnd.collabio.xodocuments.spreadsheet-template\txots\napplication/vnd.collection+json\napplication/vnd.collection.doc+json\napplication/vnd.collection.next+json\napplication/vnd.comicbook-rar\t\t\tcbr\napplication/vnd.comicbook+zip\t\t\tcbz\n# icc: application/vnd.iccprofile\napplication/vnd.commerce-battelle\tica icf icd ic0 ic1 ic2 ic3 ic4 ic5 ic6 ic7 ic8\napplication/vnd.commonspace\t\t\tcsp cst\napplication/vnd.contact.cmsg\t\t\tcdbcmsg\napplication/vnd.coreos.ignition+json\t\tign ignition\napplication/vnd.cosmocaller\t\t\tcmc\napplication/vnd.crick.clicker\t\t\tclkx\napplication/vnd.crick.clicker.keyboard\t\tclkk\napplication/vnd.crick.clicker.palette\t\tclkp\napplication/vnd.crick.clicker.template\t\tclkt\napplication/vnd.crick.clicker.wordbank\t\tclkw\napplication/vnd.criticaltools.wbs+xml\t\twbs\napplication/vnd.cryptii.pipe+json\napplication/vnd.crypto-shade-file\t\tssvc\napplication/vnd.cryptomator.encrypted   c9r c9s\napplication/vnd.cryptomator.vault       cryptomator\napplication/vnd.ctc-posml\t\t\tpml\napplication/vnd.ctct.ws+xml\napplication/vnd.cups-pdf\napplication/vnd.cups-postscript\napplication/vnd.cups-ppd\t\t\tppd\napplication/vnd.cups-raster\napplication/vnd.cups-raw\napplication/vnd.curl\t\t\t\tcurl\napplication/vnd.cyan.dean.root+xml\napplication/vnd.cybank\n# json: application/json\napplication/vnd.cyclonedx+json\n# xml: text/xml\napplication/vnd.cyclonedx+xml\napplication/vnd.d2l.coursepackage1p0+zip\n# json: application/json\napplication/vnd.d3m-dataset\n# json: application/json\napplication/vnd.d3m-problem\napplication/vnd.dart\t\t\t\tdart\napplication/vnd.data-vision.rdz\t\t\trdz\napplication/vnd.datalog                 dl\napplication/vnd.datapackage+json\napplication/vnd.dataresource+json\napplication/vnd.dbf\t\t\t\tdbf\napplication/vnd.debian.binary-package\t\tdeb udeb\napplication/vnd.dece.data\t\t\tuvf uvvf uvd uvvd\napplication/vnd.dece.ttml+xml\t\t\tuvt uvvt\napplication/vnd.dece.unspecified\t\tuvx uvvx\napplication/vnd.dece.zip\t\t\tuvz uvvz\napplication/vnd.denovo.fcselayout-link\t\tfe_launch\napplication/vnd.desmume.movie\t\t\tdsm\napplication/vnd.dir-bi.plate-dl-nosuffix\napplication/vnd.dm.delegation+xml\napplication/vnd.dna\t\t\t\tdna\napplication/vnd.document+json\t\t\tdocjson\napplication/vnd.dolby.mobile.1\napplication/vnd.dolby.mobile.2\napplication/vnd.doremir.scorecloud-binary-document\tscld\napplication/vnd.dpgraph\t\t\t\tdpg mwc dpgraph\napplication/vnd.dreamfactory\t\t\tdfac\napplication/vnd.drive+json\napplication/vnd.dtg.local\napplication/vnd.dtg.local.flash\t\t\tfla\napplication/vnd.dtg.local.html\napplication/vnd.dvb.ait\t\t\t\tait\napplication/vnd.dvb.dvbisl+xml\n# class: application/octet-stream\napplication/vnd.dvb.dvbj\napplication/vnd.dvb.esgcontainer\napplication/vnd.dvb.ipdcdftnotifaccess\napplication/vnd.dvb.ipdcesgaccess\napplication/vnd.dvb.ipdcesgaccess2\napplication/vnd.dvb.ipdcesgpdd\napplication/vnd.dvb.ipdcroaming\napplication/vnd.dvb.iptv.alfec-base\napplication/vnd.dvb.iptv.alfec-enhancement\napplication/vnd.dvb.notif-aggregate-root+xml\napplication/vnd.dvb.notif-container+xml\napplication/vnd.dvb.notif-generic+xml\napplication/vnd.dvb.notif-ia-msglist+xml\napplication/vnd.dvb.notif-ia-registration-request+xml\napplication/vnd.dvb.notif-ia-registration-response+xml\napplication/vnd.dvb.notif-init+xml\n# pfr: application/font-tdpfr\napplication/vnd.dvb.pfr\napplication/vnd.dvb.service\t\t\tsvc\n# dxr: application/x-director\napplication/vnd.dxr\napplication/vnd.dynageo\t\t\t\tgeo\napplication/vnd.dzr\t\t\t\tdzr\napplication/vnd.easykaraoke.cdgdownload\napplication/vnd.ecdis-update\napplication/vnd.ecip.rlp\napplication/vnd.eclipse.ditto+json\napplication/vnd.ecowin.chart\t\t\tmag\napplication/vnd.ecowin.filerequest\napplication/vnd.ecowin.fileupdate\napplication/vnd.ecowin.series\napplication/vnd.ecowin.seriesrequest\napplication/vnd.ecowin.seriesupdate\n# img: application/octet-stream\napplication/vnd.efi.img\n# iso: application/octet-stream\napplication/vnd.efi.iso\napplication/vnd.eln+zip             eln\napplication/vnd.emclient.accessrequest+xml\napplication/vnd.enliven\t\t\t\tnml\napplication/vnd.enphase.envoy\napplication/vnd.eprints.data+xml\napplication/vnd.epson.esf\t\t\tesf\napplication/vnd.epson.msf\t\t\tmsf\napplication/vnd.epson.quickanime\t\tqam\napplication/vnd.epson.salt\t\t\tslt\napplication/vnd.epson.ssf\t\t\tssf\napplication/vnd.ericsson.quickcall\t\tqcall qca\napplication/vnd.espass-espass+zip\t\tespass\napplication/vnd.eszigno3+xml\t\t\tes3 et3\napplication/vnd.etsi.aoc+xml\napplication/vnd.etsi.asic-e+zip\t\t\tasice sce\n# scs: application/scvp-cv-response\napplication/vnd.etsi.asic-s+zip\t\t\tasics\napplication/vnd.etsi.cug+xml\napplication/vnd.etsi.iptvcommand+xml\napplication/vnd.etsi.iptvdiscovery+xml\napplication/vnd.etsi.iptvprofile+xml\napplication/vnd.etsi.iptvsad-bc+xml\napplication/vnd.etsi.iptvsad-cod+xml\napplication/vnd.etsi.iptvsad-npvr+xml\napplication/vnd.etsi.iptvservice+xml\napplication/vnd.etsi.iptvsync+xml\napplication/vnd.etsi.iptvueprofile+xml\napplication/vnd.etsi.mcid+xml\napplication/vnd.etsi.mheg5\napplication/vnd.etsi.overload-control-policy-dataset+xml\napplication/vnd.etsi.pstn+xml\napplication/vnd.etsi.sci+xml\napplication/vnd.etsi.simservs+xml\napplication/vnd.etsi.timestamp-token\t\ttst\napplication/vnd.etsi.tsl.der\napplication/vnd.eu.kasparian.car+json       carjson\napplication/vnd.etsi.tsl+xml\napplication/vnd.eudora.data\napplication/vnd.exstream-empower+zip\t\tmpw\napplication/vnd.exstream-package\t\tpub\napplication/vnd.evolv.ecig.profile\t\tecigprofile\napplication/vnd.evolv.ecig.settings\t\tecig\napplication/vnd.evolv.ecig.theme\t\tecigtheme\napplication/vnd.ezpix-album\t\t\tez2\napplication/vnd.ezpix-package\t\t\tez3\napplication/vnd.f-secure.mobile\napplication/vnd.fastcopy-disk-image\t\tdim\napplication/vnd.familysearch.gedcom+zip     gdz\napplication/vnd.fdf\t\t\t\tfdf\napplication/vnd.fdsn.mseed\t\t\tmsd mseed\napplication/vnd.fdsn.seed\t\t\tseed dataless\napplication/vnd.ffsns\napplication/vnd.ficlab.flb+zip\t\t\tflb\napplication/vnd.filmit.zfc\t\t\tzfc\n# all extensions: application/vnd.hbci\napplication/vnd.fints\napplication/vnd.firemonkeys.cloudcell\napplication/vnd.FloGraphIt\t\t\tgph\napplication/vnd.fluxtime.clip\t\t\tftc\napplication/vnd.font-fontforge-sfd\t\tsfd\napplication/vnd.framemaker\t\t\tfm\napplication/vnd.frogans.fnc\t\t\tfnc\napplication/vnd.frogans.ltf\t\t\tltf\napplication/vnd.fsc.weblaunch\t\t\tfsc\n# xdw: application/vnd.fujixerox.docuworks\napplication/vnd.fujifilm.fb.docuworks\n# xbd: application/vnd.fujixerox.docuworks.binder\napplication/vnd.fujifilm.fb.docuworks.binder\n# xct: application/vnd.fujixerox.docuworks.container\napplication/vnd.fujifilm.fb.docuworks.container\napplication/vnd.fujifilm.fb.jfi+xml\napplication/vnd.fujitsu.oasys\t\t\toas\napplication/vnd.fujitsu.oasys2\t\t\toa2\napplication/vnd.fujitsu.oasys3\t\t\toa3\napplication/vnd.fujitsu.oasysgp\t\t\tfg5\napplication/vnd.fujitsu.oasysprs\t\tbh2\napplication/vnd.fujixerox.ART-EX\napplication/vnd.fujixerox.ART4\napplication/vnd.fujixerox.ddd\t\t\tddd\napplication/vnd.fujixerox.docuworks     xdw\napplication/vnd.fujixerox.docuworks.binder      xbd\napplication/vnd.fujixerox.docuworks.container       xct\napplication/vnd.fujixerox.HBPL\napplication/vnd.fut-misnet\napplication/vnd.futoin+cbor\napplication/vnd.futoin+json\napplication/vnd.fuzzysheet\t\t\tfzs\napplication/vnd.genomatix.tuxedo\t\ttxd\napplication/vnd.genozip                 genozip\napplication/vnd.gentics.grd+json\napplication/vnd.gentoo.catmetadata+xml\napplication/vnd.gentoo.ebuild           ebuild\napplication/vnd.gentoo.eclass           eclass\napplication/vnd.gentoo.gpkg             gpkg.tar\napplication/vnd.gentoo.manifest\napplication/vnd.gentoo.xpak             tbz2 xpak\napplication/vnd.gentoo.pkgmetadata+xml\n# application/vnd.geo+json obsoleted by application/geo+json\napplication/vnd.geocube+xml\t\t\tg3 g³\napplication/vnd.geogebra.file\t\t\tggb\napplication/vnd.geogebra.slides         ggs\napplication/vnd.geogebra.tool\t\t\tggt\napplication/vnd.geometry-explorer\t\tgex gre\napplication/vnd.geonext\t\t\t\tgxt\napplication/vnd.geoplan\t\t\t\tg2w\napplication/vnd.geospace\t\t\tg3w\n# gbr: application/rpki-ghostbusters\napplication/vnd.gerber\napplication/vnd.globalplatform.card-content-mgt\napplication/vnd.globalplatform.card-content-mgt-response\napplication/vnd.gmx\t\t\t\tgmx\napplication/vnd.gnu.taler.exchange+json\napplication/vnd.gnu.taler.merchant+json\napplication/vnd.google-earth.kml+xml\t\tkml\napplication/vnd.google-earth.kmz\t\tkmz\napplication/vnd.gov.sk.e-form+xml\napplication/vnd.gov.sk.e-form+zip\napplication/vnd.gov.sk.xmldatacontainer+xml\napplication/vnd.gpxsee.map+xml\napplication/vnd.grafeq\t\t\t\tgqf gqs\napplication/vnd.gridmp\napplication/vnd.groove-account\t\t\tgac\napplication/vnd.groove-help\t\t\tghf\napplication/vnd.groove-identity-message\t\tgim\napplication/vnd.groove-injector\t\t\tgrv\napplication/vnd.groove-tool-message\t\tgtm\napplication/vnd.groove-tool-template\t\ttpl\napplication/vnd.groove-vcard\t\t\tvcg\napplication/vnd.hal+json\napplication/vnd.hal+xml\t\t\t\thal\napplication/vnd.HandHeld-Entertainment+xml\tzmm\napplication/vnd.hbci\t\t\t\thbci hbc kom upa pkd bpd\napplication/vnd.hc+json\n# rep: application/vnd.businessobjects\napplication/vnd.hcl-bireports\napplication/vnd.hdt\t\t\t\thdt\napplication/vnd.heroku+json\napplication/vnd.hhe.lesson-player\t\tles\napplication/vnd.hp-HPGL\t\t\t\thpgl\napplication/vnd.hp-hpid\t\t\t\thpi hpid\napplication/vnd.hp-hps\t\t\t\thps\napplication/vnd.hp-jlyt\t\t\t\tjlt\napplication/vnd.hp-PCL\t\t\t\tpcl\napplication/vnd.hp-PCLXL\napplication/vnd.hsl                 hsl\napplication/vnd.httphone\napplication/vnd.hydrostatix.sof-data\t\tsfd-hdstx\napplication/vnd.hyper+json\napplication/vnd.hyper-item+json\napplication/vnd.hyperdrive+json\napplication/vnd.hzn-3d-crossword\t\tx3d\n# application/vnd.ibm.afplinedata obsoleted by application/vnd.afpc.afplinedata\napplication/vnd.ibm.electronic-media\t\temm\napplication/vnd.ibm.MiniPay\t\t\tmpy\n# application/vnd.ibm.modcap obsoleted by application/vnd.afpc.modca\napplication/vnd.ibm.rights-management\t\tirm\napplication/vnd.ibm.secure-container\t\tsc\napplication/vnd.iccprofile\t\t\ticc icm\napplication/vnd.ieee.1905\t\t\t1905.1\napplication/vnd.igloader\t\t\tigl\napplication/vnd.imagemeter.folder+zip\t\timf\napplication/vnd.imagemeter.image+zip\t\timi\napplication/vnd.immervision-ivp\t\t\tivp\napplication/vnd.immervision-ivu\t\t\tivu\napplication/vnd.ims.imsccv1p1\t\t\timscc\napplication/vnd.ims.imsccv1p2\napplication/vnd.ims.imsccv1p3\napplication/vnd.ims.lis.v2.result+json\napplication/vnd.ims.lti.v2.toolconsumerprofile+json\napplication/vnd.ims.lti.v2.toolproxy.id+json\napplication/vnd.ims.lti.v2.toolproxy+json\napplication/vnd.ims.lti.v2.toolsettings+json\napplication/vnd.ims.lti.v2.toolsettings.simple+json\napplication/vnd.informedcontrol.rms+xml\n# application/vnd.informix-visionary obsoleted by application/vnd.visionary\napplication/vnd.infotech.project\napplication/vnd.infotech.project+xml\napplication/vnd.innopath.wamp.notification\napplication/vnd.insors.igm\t\t\tigm\napplication/vnd.intercon.formnet\t\txpw xpx\napplication/vnd.intergeo\t\t\ti2g\napplication/vnd.intertrust.digibox\napplication/vnd.intertrust.nncp\napplication/vnd.intu.qbo\t\t\tqbo\napplication/vnd.intu.qfx\t\t\tqfx\napplication/vnd.ipld.car            car\napplication/vnd.ipld.dag-cbor\napplication/vnd.ipld.dag-json\napplication/vnd.ipld.raw\napplication/vnd.iptc.g2.catalogitem+xml\napplication/vnd.iptc.g2.conceptitem+xml\napplication/vnd.iptc.g2.knowledgeitem+xml\napplication/vnd.iptc.g2.newsitem+xml\napplication/vnd.iptc.g2.newsmessage+xml\napplication/vnd.iptc.g2.packageitem+xml\napplication/vnd.iptc.g2.planningitem+xml\napplication/vnd.ipunplugged.rcprofile\t\trcprofile\napplication/vnd.irepository.package+xml\t\tirp\napplication/vnd.is-xpr\t\t\t\txpr\napplication/vnd.isac.fcs\t\t\tfcs\napplication/vnd.jam\t\t\t\tjam\napplication/vnd.iso11783-10+zip\napplication/vnd.japannet-directory-service\napplication/vnd.japannet-jpnstore-wakeup\napplication/vnd.japannet-payment-wakeup\napplication/vnd.japannet-registration\napplication/vnd.japannet-registration-wakeup\napplication/vnd.japannet-setstore-wakeup\napplication/vnd.japannet-verification\napplication/vnd.japannet-verification-wakeup\napplication/vnd.jcp.javame.midlet-rms\t\trms\napplication/vnd.jisp\t\t\t\tjisp\napplication/vnd.joost.joda-archive\t\tjoda\napplication/vnd.jsk.isdn-ngn\napplication/vnd.kahootz\t\t\t\tktz ktr\napplication/vnd.kde.karbon\t\t\tkarbon\napplication/vnd.kde.kchart\t\t\tchrt\napplication/vnd.kde.kformula\t\t\tkfo\napplication/vnd.kde.kivio\t\t\tflw\napplication/vnd.kde.kontour\t\t\tkon\napplication/vnd.kde.kpresenter\t\t\tkpr kpt\napplication/vnd.kde.kspread\t\t\tksp\napplication/vnd.kde.kword\t\t\tkwd kwt\napplication/vnd.kenameaapp\t\t\thtke\napplication/vnd.kidspiration\t\t\tkia\napplication/vnd.Kinar\t\t\t\tkne knp sdf\napplication/vnd.koan\t\t\t\tskp skd skm skt\napplication/vnd.kodak-descriptor\t\tsse\napplication/vnd.las                 las\napplication/vnd.las.las+json\t\t\tlasjson\napplication/vnd.las.las+xml\t\t\tlasxml\napplication/vnd.laszip\napplication/vnd.leap+json\napplication/vnd.liberty-request+xml\napplication/vnd.llamagraphics.life-balance.desktop\tlbd\napplication/vnd.llamagraphics.life-balance.exchange+xml\tlbe\napplication/vnd.logipipe.circuit+zip\t\tlcs lca\napplication/vnd.loom\t\t\t\tloom\napplication/vnd.lotus-1-2-3\t\t\t123 wk4 wk3 wk1\napplication/vnd.lotus-approach\t\t\tapr vew\napplication/vnd.lotus-freelance\t\t\tprz pre\napplication/vnd.lotus-notes\t\t\tnsf ntf ndl ns4 ns3 ns2 nsh nsg\napplication/vnd.lotus-organizer\t\t\tor3 or2 org\napplication/vnd.lotus-screencam\t\t\tscm\napplication/vnd.lotus-wordpro\t\t\tlwp sam\napplication/vnd.macports.portpkg\t\tportpkg\napplication/vnd.mapbox-vector-tile\t\tmvt\napplication/vnd.marlin.drm.actiontoken+xml\napplication/vnd.marlin.drm.conftoken+xml\napplication/vnd.marlin.drm.license+xml\napplication/vnd.marlin.drm.mdcf\t\t\tmdc\napplication/vnd.mason+json\napplication/vnd.maxar.archive.3tz+zip   3tz\napplication/vnd.maxmind.maxmind-db\t\tmmdb\napplication/vnd.mcd\t\t\t\tmcd\napplication/vnd.mdl             mdl\napplication/vnd.mdl-mbsdf       mbsdf\napplication/vnd.medcalcdata\t\t\tmc1\napplication/vnd.mediastation.cdkey\t\tcdkey\napplication/vnd.medicalholodeck.recordxr    rxr\napplication/vnd.meridian-slingshot\napplication/vnd.MFER\t\t\t\tmwf\napplication/vnd.mfmp\t\t\t\tmfm\napplication/vnd.micro+json\napplication/vnd.micrografx.flo\t\t\tflo\napplication/vnd.micrografx.igx\t\t\tigx\napplication/vnd.microsoft.portable-executable\napplication/vnd.microsoft.windows.thumbnail-cache\napplication/vnd.miele+json\napplication/vnd.mif\t\t\t\tmif\napplication/vnd.minisoft-hp3000-save\napplication/vnd.mitsubishi.misty-guard.trustweb\napplication/vnd.Mobius.DAF\t\t\tdaf\napplication/vnd.Mobius.DIS\t\t\tdis\napplication/vnd.Mobius.MBK\t\t\tmbk\napplication/vnd.Mobius.MQY\t\t\tmqy\napplication/vnd.Mobius.MSL\t\t\tmsl\napplication/vnd.Mobius.PLC\t\t\tplc\napplication/vnd.Mobius.TXF\t\t\ttxf\napplication/vnd.modl                modl\napplication/vnd.mophun.application\t\tmpn\napplication/vnd.mophun.certificate\t\tmpc\napplication/vnd.motorola.flexsuite\napplication/vnd.motorola.flexsuite.adsi\napplication/vnd.motorola.flexsuite.fis\napplication/vnd.motorola.flexsuite.gotap\napplication/vnd.motorola.flexsuite.kmr\napplication/vnd.motorola.flexsuite.ttc\napplication/vnd.motorola.flexsuite.wem\napplication/vnd.motorola.iprm\napplication/vnd.mozilla.xul+xml\t\t\txul\napplication/vnd.ms-3mfdocument\t\t\t3mf\napplication/vnd.ms-artgalry\t\t\tcil\napplication/vnd.ms-asf\t\t\t\tasf\napplication/vnd.ms-cab-compressed\t\tcab\napplication/vnd.ms-excel\t\t\txls xlm xla xlc xlt xlw\napplication/vnd.ms-excel.template.macroEnabled.12\txltm\napplication/vnd.ms-excel.addin.macroEnabled.12\txlam\napplication/vnd.ms-excel.sheet.binary.macroEnabled.12\txlsb\napplication/vnd.ms-excel.sheet.macroEnabled.12\txlsm\napplication/vnd.ms-fontobject\t\t\teot\napplication/vnd.ms-htmlhelp\t\t\tchm\napplication/vnd.ms-ims\t\t\t\tims\napplication/vnd.ms-lrm\t\t\t\tlrm\napplication/vnd.ms-office.activeX+xml\napplication/vnd.ms-officetheme\t\t\tthmx\napplication/vnd.ms-playready.initiator+xml\napplication/vnd.ms-powerpoint\t\t\tppt pps pot\napplication/vnd.ms-powerpoint.addin.macroEnabled.12\tppam\napplication/vnd.ms-powerpoint.presentation.macroEnabled.12\tpptm\napplication/vnd.ms-powerpoint.slide.macroEnabled.12\tsldm\napplication/vnd.ms-powerpoint.slideshow.macroEnabled.12\tppsm\napplication/vnd.ms-powerpoint.template.macroEnabled.12\tpotm\napplication/vnd.ms-PrintDeviceCapabilities+xml\napplication/vnd.ms-PrintSchemaTicket+xml\napplication/vnd.ms-project\t\t\tmpp mpt\napplication/vnd.ms-tnef\t\t\t\ttnef tnf\napplication/vnd.ms-windows.devicepairing\napplication/vnd.ms-windows.nwprinting.oob\napplication/vnd.ms-windows.printerpairing\napplication/vnd.ms-windows.wsd.oob\napplication/vnd.ms-wmdrm.lic-chlg-req\napplication/vnd.ms-wmdrm.lic-resp\napplication/vnd.ms-wmdrm.meter-chlg-req\napplication/vnd.ms-wmdrm.meter-resp\napplication/vnd.ms-word.document.macroEnabled.12\tdocm\napplication/vnd.ms-word.template.macroEnabled.12\tdotm\napplication/vnd.ms-works\t\t\twcm wdb wks wps\napplication/vnd.ms-wpl\t\t\t\twpl\napplication/vnd.ms-xpsdocument\t\t\txps\napplication/vnd.msa-disk-image\t\t\tmsa\napplication/vnd.mseq\t\t\t\tmseq\napplication/vnd.msign\napplication/vnd.multiad.creator\t\t\tcrtr\napplication/vnd.multiad.creator.cif\t\tcif\napplication/vnd.music-niff\napplication/vnd.musician\t\t\tmus\napplication/vnd.muvee.style\t\t\tmsty\napplication/vnd.mynfc\t\t\t\ttaglet\napplication/vnd.nacamar.ybrid+json\napplication/vnd.ncd.control\napplication/vnd.ncd.reference\napplication/vnd.nearst.inv+json\napplication/vnd.nebumind.line       nebul line\napplication/vnd.nervana\t\t\t\tentity request bkm kcm\napplication/vnd.netfpx\napplication/vnd.nimn\t\t\t\tnimn\n# ntf: application/vnd.lotus-notes\napplication/vnd.nitf\t\t\t\tnitf\napplication/vnd.neurolanguage.nlu\t\tnlu\napplication/vnd.nintendo.nitro.rom\t\tnds\napplication/vnd.nintendo.snes.rom\t\tsfc smc\napplication/vnd.noblenet-directory\t\tnnd\napplication/vnd.noblenet-sealer\t\t\tnns\napplication/vnd.noblenet-web\t\t\tnnw\napplication/vnd.nokia.catalogs\napplication/vnd.nokia.conml+wbxml\napplication/vnd.nokia.conml+xml\napplication/vnd.nokia.iptv.config+xml\napplication/vnd.nokia.iSDS-radio-presets\napplication/vnd.nokia.landmark+wbxml\napplication/vnd.nokia.landmark+xml\napplication/vnd.nokia.landmarkcollection+xml\napplication/vnd.nokia.n-gage.ac+xml\t\tac\napplication/vnd.nokia.n-gage.data\t\tngdat\napplication/vnd.nokia.n-gage.symbian.install\tn-gage\napplication/vnd.nokia.ncd\napplication/vnd.nokia.pcd+wbxml\napplication/vnd.nokia.pcd+xml\napplication/vnd.nokia.radio-preset\t\trpst\napplication/vnd.nokia.radio-presets\t\trpss\napplication/vnd.novadigm.EDM\t\t\tedm\napplication/vnd.novadigm.EDX\t\t\tedx\napplication/vnd.novadigm.EXT\t\t\text\napplication/vnd.ntt-local.content-share\napplication/vnd.ntt-local.file-transfer\napplication/vnd.ntt-local.ogw_remote-access\napplication/vnd.ntt-local.sip-ta_remote\napplication/vnd.ntt-local.sip-ta_tcp_stream\napplication/vnd.oasis.opendocument.base             odb\napplication/vnd.oasis.opendocument.chart\t\t\todc\napplication/vnd.oasis.opendocument.chart-template\t\totc\n# application/vnd.oasis.opendocument.database obsoleted by application/vnd.oasis.opendocument.base\napplication/vnd.oasis.opendocument.formula\t\t\todf\n# otf: font/otf\napplication/vnd.oasis.opendocument.formula-template\napplication/vnd.oasis.opendocument.graphics\t\t\todg\napplication/vnd.oasis.opendocument.graphics-template\t\totg\napplication/vnd.oasis.opendocument.image\t\t\todi\napplication/vnd.oasis.opendocument.image-template\t\toti\napplication/vnd.oasis.opendocument.presentation\t\t\todp\napplication/vnd.oasis.opendocument.presentation-template\totp\napplication/vnd.oasis.opendocument.spreadsheet\t\t\tods\napplication/vnd.oasis.opendocument.spreadsheet-template\t\tots\napplication/vnd.oasis.opendocument.text\t\t\t\todt\napplication/vnd.oasis.opendocument.text-master\t\t\todm\napplication/vnd.oasis.opendocument.text-master-template otm\napplication/vnd.oasis.opendocument.text-template\t\tott\napplication/vnd.oasis.opendocument.text-web\t\t\toth\napplication/vnd.obn\napplication/vnd.ocf+cbor\napplication/vnd.oci.image.manifest.v1+json\napplication/vnd.oftn.l10n+json\napplication/vnd.oipf.contentaccessdownload+xml\napplication/vnd.oipf.contentaccessstreaming+xml\napplication/vnd.oipf.cspg-hexbinary\napplication/vnd.oipf.dae.svg+xml\napplication/vnd.oipf.dae.xhtml+xml\napplication/vnd.oipf.mippvcontrolmessage+xml\napplication/vnd.oipf.pae.gem\napplication/vnd.oipf.spdiscovery+xml\napplication/vnd.oipf.spdlist+xml\napplication/vnd.oipf.ueprofile+xml\napplication/vnd.oipf.userprofile+xml\napplication/vnd.olpc-sugar\t\t\txo\napplication/vnd.oma.bcast.associated-procedure-parameter+xml\napplication/vnd.oma.bcast.drm-trigger+xml\napplication/vnd.oma.bcast.imd+xml\napplication/vnd.oma.bcast.ltkm\napplication/vnd.oma.bcast.notification+xml\napplication/vnd.oma.bcast.provisioningtrigger\napplication/vnd.oma.bcast.sgboot\napplication/vnd.oma.bcast.sgdd+xml\napplication/vnd.oma.bcast.sgdu\napplication/vnd.oma.bcast.simple-symbol-container\napplication/vnd.oma.bcast.smartcard-trigger+xml\napplication/vnd.oma.bcast.sprov+xml\napplication/vnd.oma.bcast.stkm\napplication/vnd.oma.cab-address-book+xml\napplication/vnd.oma.cab-feature-handler+xml\napplication/vnd.oma.cab-pcc+xml\napplication/vnd.oma.cab-subs-invite+xml\napplication/vnd.oma.cab-user-prefs+xml\napplication/vnd.oma.dcd\napplication/vnd.oma.dcdc\napplication/vnd.oma.dd2+xml\t\t\tdd2\napplication/vnd.oma.drm.risd+xml\napplication/vnd.oma.group-usage-list+xml\napplication/vnd.oma.lwm2m+cbor\napplication/vnd.oma.lwm2m+json\napplication/vnd.oma.lwm2m+tlv\napplication/vnd.oma.pal+xml\napplication/vnd.oma.poc.detailed-progress-report+xml\napplication/vnd.oma.poc.final-report+xml\napplication/vnd.oma.poc.groups+xml\napplication/vnd.oma.poc.invocation-descriptor+xml\napplication/vnd.oma.poc.optimized-progress-report+xml\napplication/vnd.oma.push\napplication/vnd.oma.scidm.messages+xml\napplication/vnd.oma.xcap-directory+xml\napplication/vnd.oma-scws-config\napplication/vnd.oma-scws-http-request\napplication/vnd.oma-scws-http-response\napplication/vnd.omads-email+xml\napplication/vnd.omads-file+xml\napplication/vnd.omads-folder+xml\napplication/vnd.omaloc-supl-init\napplication/vnd.onepager\t\t\ttam\napplication/vnd.onepagertamp\t\t\ttamp\napplication/vnd.onepagertamx\t\t\ttamx\napplication/vnd.onepagertat\t\t\ttat\napplication/vnd.onepagertatp\t\t\ttatp\napplication/vnd.onepagertatx\t\t\ttatx\napplication/vnd.onvif.metadata\napplication/vnd.openblox.game+xml\t\tobgx\napplication/vnd.openblox.game-binary\t\tobg\napplication/vnd.openeye.oeb\t\t\toeb\napplication/vnd.openofficeorg.extension\t\toxt\napplication/vnd.openstreetmap.data+xml\t\tosm\napplication/vnd.opentimestamps.ots\napplication/vnd.openxmlformats-officedocument.custom-properties+xml\napplication/vnd.openxmlformats-officedocument.customXmlProperties+xml\napplication/vnd.openxmlformats-officedocument.drawing+xml\napplication/vnd.openxmlformats-officedocument.drawingml.chart+xml\napplication/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml\napplication/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml\napplication/vnd.openxmlformats-officedocument.drawingml.diagramData+xml\napplication/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml\napplication/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml\napplication/vnd.openxmlformats-officedocument.extended-properties+xml\napplication/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml\napplication/vnd.openxmlformats-officedocument.presentationml.comments+xml\napplication/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml\napplication/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml\napplication/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml\napplication/vnd.openxmlformats-officedocument.presentationml.presProps+xml\napplication/vnd.openxmlformats-officedocument.presentationml.presentation pptx\napplication/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml\napplication/vnd.openxmlformats-officedocument.presentationml.slide\tsldx\napplication/vnd.openxmlformats-officedocument.presentationml.slide+xml\napplication/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml\napplication/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml\napplication/vnd.openxmlformats-officedocument.presentationml.slideUpdateInfo+xml\napplication/vnd.openxmlformats-officedocument.presentationml.slideshow\tppsx\napplication/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml\napplication/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml\napplication/vnd.openxmlformats-officedocument.presentationml.tags+xml\napplication/vnd.openxmlformats-officedocument.presentationml.template\tpotx\napplication/vnd.openxmlformats-officedocument.presentationml.template.main+xml\napplication/vnd.openxmlformats-officedocument.presentationml.viewProps+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.revisionHeaders+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet\txlsx\napplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.table+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.template\txltx\napplication/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\napplication/vnd.openxmlformats-officedocument.theme+xml\napplication/vnd.openxmlformats-officedocument.themeOverride+xml\napplication/vnd.openxmlformats-officedocument.vmlDrawing\napplication/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.document\tdocx\napplication/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.template\tdotx\napplication/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml\napplication/vnd.openxmlformats-package.core-properties+xml\napplication/vnd.openxmlformats-package.digital-signature-xmlsignature+xml\napplication/vnd.openxmlformats-package.relationships+xml\napplication/vnd.oracle.resource+json\napplication/vnd.orange.indata\napplication/vnd.osa.netdeploy\t\t\tndc\napplication/vnd.osgeo.mapguide.package\t\tmgp\napplication/vnd.osgi.bundle\napplication/vnd.osgi.dp\t\t\t\tdp\napplication/vnd.osgi.subsystem\t\t\tesa\napplication/vnd.otps.ct-kip+xml\napplication/vnd.oxli.countgraph\t\t\toxlicg\napplication/vnd.pagerduty+json\napplication/vnd.palm\t\t\t\tprc pdb pqa oprc\napplication/vnd.panoply\t\t\t\tplp\napplication/vnd.paos.xml\napplication/vnd.paos+xml\napplication/vnd.patentdive\t\t\tdive\napplication/vnd.patientecommsdoc\napplication/vnd.pawaafile\t\t\tpaw\napplication/vnd.pcos\napplication/vnd.pg.format\t\t    \tstr\napplication/vnd.pg.osasli\t\t\tei6\napplication/vnd.piaccess.application-licence\tpil\napplication/vnd.picsel\t\t\t\tefif\napplication/vnd.pmi.widget\t\t\twg\napplication/vnd.poc.group-advertisement+xml\napplication/vnd.pocketlearn\t\t\tplf\napplication/vnd.powerbuilder6\t\t\tpbd\napplication/vnd.powerbuilder6-s\napplication/vnd.powerbuilder7\napplication/vnd.powerbuilder7-s\napplication/vnd.powerbuilder75\napplication/vnd.powerbuilder75-s\napplication/vnd.preminet\t\t\tpreminet\napplication/vnd.previewsystems.box\t\tbox vbox\napplication/vnd.proteus.magazine\t\tmgz\napplication/vnd.psfs\t\t\t\tpsfs\napplication/vnd.pt.mundusmundi\napplication/vnd.publishare-delta-tree\t\tqps\n# pti: image/prs.pti\napplication/vnd.pvi.ptid1\t\t\tptid\napplication/vnd.pwg-multiplexed\napplication/vnd.pwg-xhtml-print+xml\napplication/vnd.qualcomm.brew-app-res\t\tbar\napplication/vnd.quarantainenet\napplication/vnd.Quark.QuarkXPress\t\tqxd qxt qwd qwt qxl qxb\napplication/vnd.quobject-quoxdocument\t\tquox quiz\napplication/vnd.radisys.moml+xml\napplication/vnd.radisys.msml-audit-conf+xml\napplication/vnd.radisys.msml-audit-conn+xml\napplication/vnd.radisys.msml-audit-dialog+xml\napplication/vnd.radisys.msml-audit-stream+xml\napplication/vnd.radisys.msml-audit+xml\napplication/vnd.radisys.msml-conf+xml\napplication/vnd.radisys.msml-dialog-base+xml\napplication/vnd.radisys.msml-dialog-fax-detect+xml\napplication/vnd.radisys.msml-dialog-fax-sendrecv+xml\napplication/vnd.radisys.msml-dialog-group+xml\napplication/vnd.radisys.msml-dialog-speech+xml\napplication/vnd.radisys.msml-dialog-transform+xml\napplication/vnd.radisys.msml-dialog+xml\napplication/vnd.radisys.msml+xml\napplication/vnd.rainstor.data\t\t\ttree\napplication/vnd.rapid\napplication/vnd.rar\t\t\t\trar\napplication/vnd.realvnc.bed\t\t\tbed\napplication/vnd.recordare.musicxml\t\tmxl\napplication/vnd.recordare.musicxml+xml\napplication/vnd.RenLearn.rlprint\napplication/vnd.resilient.logic         rlm reload\napplication/vnd.restful+json\napplication/vnd.rig.cryptonote\t\t\tcryptonote\napplication/vnd.route66.link66+xml\t\tlink66\n# gbr: application/rpki-ghostbusters\napplication/vnd.rs-274x\napplication/vnd.ruckus.download\napplication/vnd.s3sms\napplication/vnd.sailingtracker.track\t\tst\napplication/vnd.sar\t\t\t\tSAR\napplication/vnd.sbm.cid\napplication/vnd.sbm.mid2\napplication/vnd.scribus\t\t\t\tscd sla slaz\napplication/vnd.sealed.3df\t\t\ts3df\napplication/vnd.sealed.csf\t\t\tscsf\napplication/vnd.sealed.doc\t\t\tsdoc sdo s1w\napplication/vnd.sealed.eml\t\t\tseml sem\napplication/vnd.sealed.mht\t\t\tsmht smh\napplication/vnd.sealed.net\n# spp: application/scvp-vp-response\napplication/vnd.sealed.ppt\t\t\tsppt s1p\napplication/vnd.sealed.tiff\t\t\tstif\napplication/vnd.sealed.xls\t\t\tsxls sxl s1e\n# stm: audio/x-stm\napplication/vnd.sealedmedia.softseal.html\tstml s1h\napplication/vnd.sealedmedia.softseal.pdf\tspdf spd s1a\napplication/vnd.seemail\t\t\t\tsee\n# json: application/json\napplication/vnd.seis+json\napplication/vnd.sema\t\t\t\tsema\napplication/vnd.semd\t\t\t\tsemd\napplication/vnd.semf\t\t\t\tsemf\napplication/vnd.shade-save-file\t\t\tssv\napplication/vnd.shana.informed.formdata\t\tifm\napplication/vnd.shana.informed.formtemplate\titp\napplication/vnd.shana.informed.interchange\tiif\napplication/vnd.shana.informed.package\t\tipk\napplication/vnd.shootproof+json\napplication/vnd.shopkick+json\napplication/vnd.shp\t\t\t\tshp\napplication/vnd.shx\t\t\t\tshx\napplication/vnd.sigrok.session\t\t\tsr\napplication/vnd.SimTech-MindMapper\t\ttwd twds\napplication/vnd.siren+json\napplication/vnd.smaf\t\t\t\tmmf\napplication/vnd.smart.notebook\t\t\tnotebook\napplication/vnd.smart.teacher\t\t\tteacher\napplication/vnd.smintio.portals.archive sipa\napplication/vnd.snesdev-page-table\t\tptrom pt\napplication/vnd.software602.filler.form+xml\tfo\napplication/vnd.software602.filler.form-xml-zip\tzfo\napplication/vnd.solent.sdkm+xml\t\t\tsdkm sdkd\napplication/vnd.spotfire.dxp\t\t\tdxp\napplication/vnd.spotfire.sfs\t\t\tsfs\n# db: too generic\napplication/vnd.sqlite3\t\t\t\tsqlite sqlite3\napplication/vnd.sss-cod\napplication/vnd.sss-dtf\napplication/vnd.sss-ntf\napplication/vnd.stepmania.package\t\tsmzip\napplication/vnd.stepmania.stepchart\t\tsm\napplication/vnd.street-stream\napplication/vnd.sun.wadl+xml\t\t\twadl\napplication/vnd.sus-calendar\t\t\tsus susp\napplication/vnd.svd\napplication/vnd.swiftview-ics\napplication/vnd.sybyl.mol2          ml2 mol2 sy2\napplication/vnd.sycle+xml           scl\napplication/vnd.syft+json           syft.json\napplication/vnd.syncml+xml\t\t\txsm\napplication/vnd.syncml.dm+wbxml\t\t\tbdm\napplication/vnd.syncml.dm+xml\t\t\txdm\napplication/vnd.syncml.dm.notification\napplication/vnd.syncml.dmddf+wbxml\napplication/vnd.syncml.dmddf+xml\t\tddf\napplication/vnd.syncml.dmtnds+wbxml\napplication/vnd.syncml.dmtnds+xml\napplication/vnd.syncml.ds.notification\napplication/vnd.tableschema+json\napplication/vnd.tao.intent-module-archive\ttao\napplication/vnd.tcpdump.pcap\t\t\tpcap cap dmp\napplication/vnd.theqvd\t\t\t\tqvd\napplication/vnd.think-cell.ppttc+json\t\tppttc\napplication/vnd.tmd.mediaflex.api+xml\napplication/vnd.tml\t\t\t\tvfr viaframe\napplication/vnd.tmobile-livetv\t\t\ttmo\napplication/vnd.tri.onesource\napplication/vnd.trid.tpt\t\t\ttpt\napplication/vnd.triscape.mxs\t\t\tmxs\napplication/vnd.trueapp\t\t\t\ttra\napplication/vnd.truedoc\n# cab: application/vnd.ms-cab-compressed\napplication/vnd.ubisoft.webplayer\napplication/vnd.ufdl\t\t\t\tufdl ufd frm\napplication/vnd.uiq.theme\t\t\tutz\napplication/vnd.umajin\t\t\t\tumj\napplication/vnd.unity\t\t\t\tunityweb\napplication/vnd.uoml+xml\t\t\tuoml uo\napplication/vnd.uplanet.alert\napplication/vnd.uplanet.alert-wbxml\napplication/vnd.uplanet.bearer-choice\napplication/vnd.uplanet.bearer-choice-wbxml\napplication/vnd.uplanet.cacheop\napplication/vnd.uplanet.cacheop-wbxml\napplication/vnd.uplanet.channel\napplication/vnd.uplanet.channel-wbxml\napplication/vnd.uplanet.list\napplication/vnd.uplanet.list-wbxml\napplication/vnd.uplanet.listcmd\napplication/vnd.uplanet.listcmd-wbxml\napplication/vnd.uplanet.signal\napplication/vnd.uri-map\t\t\t\turim urimap\napplication/vnd.valve.source.material\t\tvmt\napplication/vnd.vcx\t\t\t\tvcx\n# sxi: application/vnd.sun.xml.impress\napplication/vnd.vd-study\t\t\tmxi study-inter model-inter\n# mcd: application/vnd.mcd\napplication/vnd.vectorworks\t\t\tvwx\napplication/vnd.vel+json\napplication/vnd.verimatrix.vcas\napplication/vnd.veritone.aion+json  aion vtnstd\napplication/vnd.veryant.thin\t\t\tistc isws\napplication/vnd.ves.encrypted\t\t\tVES\napplication/vnd.vidsoft.vidconference\t\tvsc\napplication/vnd.visio\t\t\t\tvsd vst vsw vss\napplication/vnd.visionary\t\t\tvis\n# vsc: application/vnd.vidsoft.vidconference\napplication/vnd.vividence.scriptfile\napplication/vnd.vsf\t\t\t\tvsf\napplication/vnd.wap.sic\t\t\t\tsic\napplication/vnd.wap.slc\t\t\t\tslc\napplication/vnd.wap.wbxml\t\t\twbxml\napplication/vnd.wap.wmlc\t\t\twmlc\napplication/vnd.wap.wmlscriptc\t\t\twmlsc\napplication/vnd.wasmflow.wafl       wafl\napplication/vnd.webturbo\t\t\twtb\napplication/vnd.wfa.dpp\napplication/vnd.wfa.p2p\t\t\t\tp2p\napplication/vnd.wfa.wsc\t\t\t\twsc\napplication/vnd.windows.devicepairing\napplication/vnd.wmc\t\t\t\twmc\napplication/vnd.wmf.bootstrap\n# nb: application/mathematica for now\napplication/vnd.wolfram.mathematica\napplication/vnd.wolfram.mathematica.package\tm\napplication/vnd.wolfram.player\t\t\tnbp\napplication/vnd.wordlift\napplication/vnd.wordperfect\t\t\twpd\napplication/vnd.wqd\t\t\t\twqd\napplication/vnd.wrq-hp3000-labelled\napplication/vnd.wt.stf\t\t\t\tstf\napplication/vnd.wv.csp+xml\napplication/vnd.wv.csp+wbxml\t\t\twv\napplication/vnd.wv.ssp+xml\napplication/vnd.xacml+json\napplication/vnd.xara\t\t\t\txar\napplication/vnd.xfdl\t\t\t\txfdl xfd\napplication/vnd.xfdl.webform\napplication/vnd.xmi+xml\napplication/vnd.xmpie.cpkg\t\t\tcpkg\napplication/vnd.xmpie.dpkg\t\t\tdpkg\n# dpkg: application/vnd.xmpie.dpkg\napplication/vnd.xmpie.plan\napplication/vnd.xmpie.ppkg\t\t\tppkg\napplication/vnd.xmpie.xlim\t\t\txlim\napplication/vnd.yamaha.hv-dic\t\t\thvd\napplication/vnd.yamaha.hv-script\t\thvs\napplication/vnd.yamaha.hv-voice\t\t\thvp\napplication/vnd.yamaha.openscoreformat\t\tosf\napplication/vnd.yamaha.openscoreformat.osfpvg+xml\napplication/vnd.yamaha.remote-setup\napplication/vnd.yamaha.smaf-audio\t\tsaf\napplication/vnd.yamaha.smaf-phrase\t\tspf\napplication/vnd.yamaha.through-ngn\napplication/vnd.yamaha.tunnel-udpencap\napplication/vnd.yaoweme\t\t\t\tyme\napplication/vnd.yellowriver-custom-menu\t\tcmp\n# application/vnd.youtube.yt OBSOLETED in favor of video/vnd.youtube.yt\napplication/vnd.zul\t\t\t\tzir zirz\napplication/vnd.zzazz.deck+xml\t\t\tzaz\napplication/voicexml+xml\t\t\tvxml\napplication/voucher-cms+json\t\t\tvcj\napplication/vq-rtcpxr\napplication/wasm                    wasm\napplication/vq-rtcp-xr\napplication/watcherinfo+xml\t\t\twif\napplication/webpush-options+json\napplication/whoispp-query\napplication/whoispp-response\napplication/widget\t\t\t\twgt\napplication/wita\napplication/wordperfect5.1\napplication/wsdl+xml\t\t\t\twsdl\napplication/wspolicy+xml\t\t\twspolicy\n# yes, this *is* IANA registered despite of x-\napplication/x-pki-message\napplication/x-www-form-urlencoded\napplication/x-x509-ca-cert\napplication/x-x509-ca-ra-cert\napplication/x-x509-next-ca-cert\napplication/x400-bp\napplication/xacml+xml\napplication/xcap-att+xml\t\t\txav\napplication/xcap-caps+xml\t\t\txca\napplication/xcap-diff+xml\t\t\txdf\napplication/xcap-el+xml\t\t\t\txel\napplication/xcap-error+xml\t\t\txer\napplication/xcap-ns+xml\t\t\t\txns\napplication/xcon-conference-info-diff+xml\napplication/xcon-conference-info+xml\napplication/xenc+xml\napplication/xfdf                    xfdf\napplication/xhtml+xml\t\t\t\txhtml xhtm xht\napplication/xliff+xml\t\t\t\txlf\n# xml, xsd, rng: text/xml\napplication/xml\n# mod: audio/x-mod\napplication/xml-dtd\t\t\t\tdtd\n# ent: text/xml-external-parsed-entity\napplication/xml-external-parsed-entity\napplication/xml-patch+xml\napplication/xmpp+xml\napplication/xop+xml\t\t\t\txop\napplication/xslt+xml\t\t\t\txsl xslt\napplication/xv+xml\t\t\t\tmxml xhvml xvml xvm\napplication/yang\t\t\t\tyang\napplication/yang-data+json\napplication/yang-data+xml\napplication/yang-data+cbor\napplication/yang-patch+json\napplication/yang-patch+xml\napplication/yin+xml\t\t\t\tyin\napplication/zip\t\t\t\t\tzip\napplication/zlib\napplication/zstd\t\t\t\tzst\naudio/1d-interleaved-parityfec\naudio/32kadpcm\t\t\t\t\t726\n# 3gp, 3gpp: video/3gpp\naudio/3gpp\n# 3g2, 3gpp2: video/3gpp2\naudio/3gpp2\n# loas: audio/usac\naudio/aac\t\t\t\t\tadts aac ass\naudio/ac3\t\t\t\t\tac3\naudio/AMR\t\t\t\t\tamr\naudio/AMR-WB\t\t\t\t\tawb\naudio/amr-wb+\naudio/aptx\naudio/asc\t\t\t\t\tacn\n# aa3, omg: audio/ATRAC3\naudio/ATRAC-ADVANCED-LOSSLESS\t\t\taal\n# aa3, omg: audio/ATRAC3\naudio/ATRAC-X\t\t\t\t\tatx\naudio/ATRAC3\t\t\t\t\tat3 aa3 omg\naudio/basic\t\t\t\t\tau snd\naudio/BV16\naudio/BV32\naudio/clearmode\naudio/CN\naudio/DAT12\naudio/dls\t\t\t\t\tdls\naudio/dsr-es201108\naudio/dsr-es202050\naudio/dsr-es202211\naudio/dsr-es202212\naudio/DV\naudio/DVI4\naudio/eac3\naudio/encaprtp\naudio/EVRC\t\t\t\t\tevc\n# qcp: audio/qcelp\naudio/EVRC-QCP\naudio/EVRC0\naudio/EVRC1\naudio/EVRCB\t\t\t\t\tevb\naudio/EVRCB0\naudio/EVRCB1\naudio/EVRCNW\t\t\t\t\tenw\naudio/EVRCNW0\naudio/EVRCNW1\naudio/EVRCWB\t\t\t\t\tevw\naudio/EVRCWB0\naudio/EVRCWB1\naudio/EVS\naudio/example\naudio/flexfec\naudio/fwdred\naudio/G711-0\naudio/G719\naudio/G722\naudio/G7221\naudio/G723\naudio/G726-16\naudio/G726-24\naudio/G726-32\naudio/G726-40\naudio/G728\naudio/G729\naudio/G7291\naudio/G729D\naudio/G729E\naudio/GSM\naudio/GSM-EFR\naudio/GSM-HR-08\naudio/iLBC\t\t\t\t\tlbc\naudio/ip-mr_v2.5\n# wav: audio/x-wav\naudio/L16\t\t\t\t\tl16\naudio/L20\naudio/L24\naudio/L8\naudio/LPC\naudio/MELP\naudio/MELP600\naudio/MELP1200\naudio/MELP2400\naudio/mhas\t\t\t\t\tmhas\naudio/mobile-xmf\t\t\t\tmxmf\n# mp4, mpg4: video/mp4, see RFC 4337\naudio/mp4\t\t\t\t\tm4a\naudio/MP4A-LATM\naudio/MPA\naudio/mpa-robust\naudio/mpeg\t\t\t\t\tmp3 mpga mp1 mp2\naudio/mpeg4-generic\naudio/ogg\t\t\t\t\toga ogg opus spx\naudio/opus\naudio/parityfec\naudio/PCMA\naudio/PCMA-WB\naudio/PCMU\naudio/PCMU-WB\naudio/prs.sid\t\t\t\t\tsid psid\naudio/QCELP\t\t\t\t\tqcp\naudio/raptorfec\naudio/RED\naudio/rtp-enc-aescm128\naudio/rtp-midi\naudio/rtploopback\naudio/rtx\naudio/scip\naudio/SMV\t\t\t\t\tsmv\n# qcp: audio/qcelp, see RFC 3625\naudio/SMV-QCP\naudio/sofa                  sofa\naudio/SMV0\n# mid: audio/midi\naudio/sp-midi\naudio/speex\naudio/t140c\naudio/t38\naudio/telephone-event\naudio/TETRA_ACELP\naudio/TETRA_ACELP_BB\naudio/tone\naudio/TSVCIS\naudio/UEMCLIP\naudio/ulpfec\naudio/usac\t\t\t\t\tloas xhe\naudio/VDVI\naudio/VMR-WB\naudio/vnd.3gpp.iufp\naudio/vnd.4SB\naudio/vnd.audiokoz\t\t\t\tkoz\naudio/vnd.CELP\naudio/vnd.cisco.nse\naudio/vnd.cmles.radio-events\naudio/vnd.cns.anp1\naudio/vnd.cns.inf1\naudio/vnd.dece.audio\t\t\t\tuva uvva\naudio/vnd.digital-winds\t\t\t\teol\naudio/vnd.dlna.adts\naudio/vnd.dolby.heaac.1\naudio/vnd.dolby.heaac.2\naudio/vnd.dolby.mlp\t\t\t\tmlp\naudio/vnd.dolby.mps\naudio/vnd.dolby.pl2\naudio/vnd.dolby.pl2x\naudio/vnd.dolby.pl2z\naudio/vnd.dolby.pulse.1\naudio/vnd.dra\n# wav: audio/x-wav, cpt: application/mac-compactpro\naudio/vnd.dts\t\t\t\t\tdts\naudio/vnd.dts.hd\t\t\t\tdtshd\naudio/vnd.dts.uhd\n# dvb: video/vnd.dvb.file\naudio/vnd.dvb.file\naudio/vnd.everad.plj\t\t\t\tplj\n# rm: audio/x-pn-realaudio\naudio/vnd.hns.audio\naudio/vnd.lucent.voice\t\t\t\tlvp\naudio/vnd.ms-playready.media.pya\t\tpya\n# mxmf: audio/mobile-xmf\naudio/vnd.nokia.mobile-xmf\naudio/vnd.nortel.vbk\t\t\t\tvbk\naudio/vnd.nuera.ecelp4800\t\t\tecelp4800\naudio/vnd.nuera.ecelp7470\t\t\tecelp7470\naudio/vnd.nuera.ecelp9600\t\t\tecelp9600\naudio/vnd.octel.sbc\naudio/vnd.presonus.multitrack\t\t\tmultitrack\n# audio/vnd.qcelp deprecated in favour of audio/qcelp\naudio/vnd.rhetorex.32kadpcm\naudio/vnd.rip\t\t\t\t\trip\naudio/vnd.sealedmedia.softseal.mpeg\t\tsmp3 smp s1m\naudio/vnd.vmx.cvsd\naudio/vorbis\naudio/vorbis-config\nfont/collection\t\t\t\t\tttc\nfont/otf\t\t\t\t\totf\nfont/sfnt\nfont/ttf\t\t\t\t\tttf\nfont/woff\t\t\t\t\twoff\nfont/woff2\t\t\t\t\twoff2\nimage/aces\t\t\t\t\texr\nimage/apng\nimage/avci\t\t\t\t\tavci\nimage/avcs\t\t\t\t\tavcs\n# heif: image/heif\n# heifs: images/heif-sequence\nimage/avif                  avif hif\nimage/bmp\t\t\t\t\tbmp dib\nimage/cgm\t\t\t\t\tcgm\nimage/dicom-rle\t\t\t\t\tdrle\nimage/dpx                   dpx\nimage/emf\t\t\t\t\temf\nimage/example\nimage/fits\t\t\t\t\tfits fit fts\nimage/g3fax\nimage/heic\t\t\t\t\theic\nimage/heic-sequence\t\t\t\theics\nimage/heif\t\t\t\t\theif\nimage/heif-sequence\t\t\t\theifs\nimage/hej2k\t\t\t\t\thej2\nimage/hsj2\t\t\t\t\thsj2\nimage/gif\t\t\t\t\tgif\nimage/ief\t\t\t\t\tief\nimage/jls\t\t\t\t\tjls\nimage/jp2\t\t\t\t\tjp2 jpg2\nimage/jph\t\t\t\t\tjph\nimage/jphc\t\t\t\t\tjhc\nimage/jpeg\t\t\t\t\tjpg jpeg jpe jfif\nimage/jpm\t\t\t\t\tjpm jpgm\nimage/jpx\t\t\t\t\tjpx jpf\nimage/jxl                   jxl\nimage/jxr\t\t\t\t\tjxr\nimage/jxrA\t\t\t\t\tjxra\nimage/jxrS\t\t\t\t\tjxrs\nimage/jxs\t\t\t\t\tjxs\nimage/jxsc\t\t\t\t\tjxsc\nimage/jxsi\t\t\t\t\tjxsi\nimage/jxss\t\t\t\t\tjxss\nimage/ktx\t\t\t\t\tktx\nimage/ktx2                  ktx2\nimage/naplps\nimage/png\t\t\t\t\tpng\nimage/prs.btif\t\t\t\t\tbtif btf\nimage/prs.pti\t\t\t\t\tpti\nimage/pwg-raster\nimage/svg+xml\t\t\t\t\tsvg svgz\nimage/t38\t\t\t\t\tt38\nimage/tiff\t\t\t\t\ttiff tif\nimage/tiff-fx\t\t\t\t\ttfx\nimage/vnd.adobe.photoshop\t\t\tpsd\nimage/vnd.airzip.accelerator.azv\t\tazv\nimage/vnd.cns.inf2\nimage/vnd.dece.graphic\t\t\t\tuvi uvvi uvg uvvg\nimage/vnd.djvu\t\t\t\t\tdjvu djv\n# sub: text/vnd.dvb.subtitle\nimage/vnd.dvb.subtitle\nimage/vnd.dwg\t\t\t\t\tdwg\nimage/vnd.dxf\t\t\t\t\tdxf\nimage/vnd.fastbidsheet\t\t\t\tfbs\nimage/vnd.fpx\t\t\t\t\tfpx\nimage/vnd.fst\t\t\t\t\tfst\nimage/vnd.fujixerox.edmics-mmr\t\t\tmmr\nimage/vnd.fujixerox.edmics-rlc\t\t\trlc\nimage/vnd.globalgraphics.pgb\t\t\tpgb\nimage/vnd.microsoft.icon\t\t\tico\nimage/vnd.mix\nimage/vnd.mozilla.apng\t\t\t\tapng\nimage/vnd.ms-modi\t\t\t\tmdi\nimage/vnd.net-fpx\nimage/vnd.pco.b16               b16\nimage/vnd.radiance\t\t\t\thdr rgbe xyze\nimage/vnd.sealed.png\t\t\t\tspng spn s1n\nimage/vnd.sealedmedia.softseal.gif\t\tsgif sgi s1g\nimage/vnd.sealedmedia.softseal.jpg\t\tsjpg sjp s1j\nimage/vnd.svf\nimage/vnd.tencent.tap\t\t\t\ttap\nimage/vnd.valve.source.texture\t\t\tvtf\nimage/vnd.wap.wbmp\t\t\t\twbmp\nimage/vnd.xiff\t\t\t\t\txif\nimage/vnd.zbrush.pcx\t\t\t\tpcx\nimage/wmf\t\t\t\t\twmf\nmessage/bhttp\nmessage/CPIM\nmessage/delivery-status\nmessage/disposition-notification\nmessage/example\nmessage/external-body\nmessage/feedback-report\nmessage/global\t\t\t\t\tu8msg\nmessage/global-delivery-status\t\t\tu8dsn\nmessage/global-disposition-notification\t\tu8mdn\nmessage/global-headers\t\t\t\tu8hdr\nmessage/http\n# cl: application/simple-filter+xml\nmessage/imdn+xml\nmessage/mls\n# message/news obsoleted by message/rfc822\nmessage/ohttp-req\nmessage/ohttp-res\nmessage/partial\nmessage/rfc822\t\t\t\t\teml mail art\nmessage/s-http\nmessage/sip\nmessage/sipfrag\nmessage/tracking-status\nmessage/vnd.si.simp\n# wsc: application/vnd.wfa.wsc\nmessage/vnd.wfa.wsc\n# 3mf: application/vnd.ms-3mfdocument\nmodel/3mf\nmodel/e57\nmodel/example\nmodel/gltf-binary\t\t\t\tglb\nmodel/gltf+json\t\t\t\t\tgltf\nmodel/JT                        jt\nmodel/iges\t\t\t\t\tigs iges\nmodel/mesh\t\t\t\t\tmsh mesh silo\nmodel/mtl\t\t\t\t\tmtl\nmodel/obj\t\t\t\t\tobj\nmodel/prc\nmodel/step\nmodel/step+xml              stpx\nmodel/step+zip\nmodel/step-xml+zip          stpxz\nmodel/stl\t\t\t\t\tstl\nmodel/u3d                   u3d\nmodel/vnd.bary              bary\nmodel/vnd.cld               cld\nmodel/vnd.collada+xml\t\t\t\tdae\nmodel/vnd.dwf\t\t\t\t\tdwf\n# 3dml, 3dm: text/vnd.in3d.3dml\nmodel/vnd.flatland.3dml\nmodel/vnd.gdl\t\t\t\t\tgdl gsm win dor lmp rsm msm ism\nmodel/vnd.gs-gdl\nmodel/vnd.gtw\t\t\t\t\tgtw\nmodel/vnd.moml+xml\t\t\t\tmoml\nmodel/vnd.mts\t\t\t\t\tmts\nmodel/vnd.opengex\t\t\t\togex\nmodel/vnd.parasolid.transmit.binary\t\tx_b xmt_bin\nmodel/vnd.parasolid.transmit.text\t\tx_t xmt_txt\nmodel/vnd.pytha.pyox            pyo pyox\nmodel/vnd.rosette.annotated-data-model\nmodel/vnd.sap.vds               vds\nmodel/vnd.usda                  usda\nmodel/vnd.usdz+zip\t\t\t\tusdz\nmodel/vnd.valve.source.compiled-map\t\tbsp\nmodel/vnd.vtu\t\t\t\t\tvtu\nmodel/vrml\t\t\t\t\twrl vrml\n# x3db: model/x3d+xml\nmodel/x3d+fastinfoset\n# x3d: application/vnd.hzn-3d-crossword\nmodel/x3d+xml\t\t\t\t\tx3db\nmodel/x3d-vrml\t\t\t\t\tx3dv x3dvz\nmultipart/alternative\nmultipart/appledouble\nmultipart/byteranges\nmultipart/digest\nmultipart/encrypted\nmultipart/form-data\nmultipart/header-set\nmultipart/mixed\nmultipart/multilingual\nmultipart/parallel\nmultipart/related\nmultipart/report\nmultipart/signed\nmultipart/vnd.bint.med-plus\t\t\tbmed\nmultipart/voice-message\t\t\t\tvpm\nmultipart/x-mixed-replace\ntext/1d-interleaved-parityfec\ntext/cache-manifest\t\t\t\tappcache manifest\ntext/calendar\t\t\t\t\tics ifb\ntext/cql                    CQL\ntext/cql-expression\ntext/cql-identifier\ntext/css\t\t\t\t\tcss\ntext/csv\t\t\t\t\tcsv\ntext/csv-schema\t\t\t\t\tcsvs\ntext/directory\ntext/dns\t\t\t\t\tsoa zone\ntext/encaprtp\ntext/fhirpath\n# text/ecmascript obsoleted by application/ecmascript\ntext/enriched\ntext/example\ntext/flexfec\ntext/fwdred\ntext/gff3                   gff3\ntext/grammar-ref-list\ntext/hl7v2\ntext/html\t\t\t\t\thtml htm\ntext/javascript                 js mjs\ntext/jcr-cnd\t\t\t\t\tcnd\ntext/markdown\t\t\t\t\tmarkdown md\ntext/mizar\t\t\t\t\tmiz\ntext/n3\t\t\t\t\t\tn3\ntext/parameters\ntext/parityfec\ntext/plain\t\ttxt asc text pm el c h cc hh cxx hxx f90 conf log\ntext/provenance-notation\t\t\tprovn\ntext/prs.fallenstein.rst\t\t\trst\ntext/prs.lines.tag\t\t\t\ttag dsc\ntext/prs.prop.logic\ntext/raptorfec\ntext/RED\ntext/rfc822-headers\ntext/richtext\t\t\t\t\trtx\n# rtf: application/rtf\ntext/rtf\ntext/rtp-enc-aescm128\ntext/rtploopback\ntext/rtx\ntext/SGML\t\t\t\t\tsgml sgm\ntext/shaclc                 shaclc shc\ntext/shex                   shex\ntext/spdx                   spdx\ntext/strings\ntext/t140\ntext/tab-separated-values\t\t\ttsv\ntext/troff\t\t\t\t\tt tr roff\ntext/turtle\t\t\t\t\tttl\ntext/ulpfec\ntext/uri-list\t\t\t\t\turis uri\ntext/vcard\t\t\t\t\tvcf vcard\ntext/vnd.a\t\t\t\t\ta\ntext/vnd.abc\t\t\t\t\tabc\ntext/vnd.ascii-art\t\t\t\tascii\n# curl: application/vnd.curl\ntext/vnd.curl\ntext/vnd.debian.copyright\t\t\tcopyright\ntext/vnd.DMClientScript\t\t\t\tdms\ntext/vnd.dvb.subtitle\t\t\t\tsub\ntext/vnd.esmertec.theme-descriptor\t\tjtd\ntext/vnd.exchangeable           vfk\ntext/vnd.familysearch.gedcom    ged\ntext/vnd.ficlab.flt\t\t\t\tflt\ntext/vnd.fly\t\t\t\t\tfly\ntext/vnd.fmi.flexstor\t\t\t\tflx\n# gml: application/gml+xml\ntext/vnd.gml\ntext/vnd.graphviz\t\t\t\tgv dot\ntext/vnd.hans                   hans\ntext/vnd.hgl\t\t\t\t\thgl\ntext/vnd.in3d.3dml\t\t\t\t3dml 3dm\ntext/vnd.in3d.spot\t\t\t\tspot spo\ntext/vnd.IPTC.NewsML\ntext/vnd.IPTC.NITF\ntext/vnd.latex-z\ntext/vnd.motorola.reflex\ntext/vnd.ms-mediapackage\t\t\tmpf\ntext/vnd.net2phone.commcenter.command\t\tccc\ntext/vnd.radisys.msml-basic-layout\ntext/vnd.senx.warpscript\t\t\tmc2\ntext/vnd.si.uricatalogue\t\t\turic\ntext/vnd.sun.j2me.app-descriptor\t\tjad\ntext/vnd.sosi\t\t\t\t\tsos\ntext/vnd.trolltech.linguist\t\t\tts\ntext/vnd.wap.si\t\t\t\t\tsi\ntext/vnd.wap.sl\t\t\t\t\tsl\ntext/vnd.wap.wml\t\t\t\twml\ntext/vnd.wap.wmlscript\t\t\t\twmls\ntext/vtt\t\t\t\t\tvtt\ntext/wgsl                   wgsl\ntext/xml\t\t\t\t\txml xsd rng\ntext/xml-external-parsed-entity\t\t\tent\nvideo/1d-interleaved-parityfec\nvideo/3gpp\t\t\t\t\t3gp 3gpp\nvideo/3gpp2\t\t\t\t\t3g2 3gpp2\nvideo/3gpp-tt\nvideo/AV1\nvideo/BMPEG\nvideo/BT656\nvideo/CelB\nvideo/DV\nvideo/encaprtp\nvideo/FFV1\nvideo/example\nvideo/flexfec\nvideo/H261\nvideo/H263\nvideo/H263-1998\nvideo/H263-2000\nvideo/H264\nvideo/H264-RCDO\nvideo/H264-SVC\nvideo/H265\nvideo/H266\nvideo/iso.segment\t\t\t\tm4s\nvideo/JPEG\nvideo/jpeg2000\nvideo/jxsv\nvideo/mj2\t\t\t\t\tmj2 mjp2\nvideo/MP1S\nvideo/MP2P\nvideo/MP2T\nvideo/mp4\t\t\t\t\tmp4 mpg4 m4v\nvideo/MP4V-ES\nvideo/mpeg\t\t\t\t\tmpeg mpg mpe m1v m2v\nvideo/mpeg4-generic\nvideo/MPV\nvideo/nv\nvideo/ogg\t\t\t\t\togv\nvideo/parityfec\nvideo/pointer\nvideo/quicktime\t\t\t\t\tmov qt\nvideo/raptorfec\nvideo/raw\nvideo/rtp-enc-aescm128\nvideo/rtploopback\nvideo/rtx\nvideo/scip\nvideo/smpte291\nvideo/SMPTE292M\nvideo/ulpfec\nvideo/vc1\nvideo/vc2\nvideo/vnd.CCTV\nvideo/vnd.dece.hd\t\t\t\tuvh uvvh\nvideo/vnd.dece.mobile\t\t\t\tuvm uvvm\nvideo/vnd.dece.mp4\t\t\t\tuvu uvvu\nvideo/vnd.dece.pd\t\t\t\tuvp uvvp\nvideo/vnd.dece.sd\t\t\t\tuvs uvvs\nvideo/vnd.dece.video\t\t\t\tuvv uvvv\nvideo/vnd.directv.mpeg\nvideo/vnd.directv.mpeg-tts\nvideo/vnd.dlna.mpeg-tts\nvideo/vnd.dvb.file\t\t\t\tdvb\nvideo/vnd.fvt\t\t\t\t\tfvt\n# rm: audio/x-pn-realaudio\nvideo/vnd.hns.video\nvideo/vnd.iptvforum.1dparityfec-1010\nvideo/vnd.iptvforum.1dparityfec-2005\nvideo/vnd.iptvforum.2dparityfec-1010\nvideo/vnd.iptvforum.2dparityfec-2005\nvideo/vnd.iptvforum.ttsavc\nvideo/vnd.iptvforum.ttsmpeg2\nvideo/vnd.motorola.video\nvideo/vnd.motorola.videop\nvideo/vnd.mpegurl\t\t\t\tmxu m4u\nvideo/vnd.ms-playready.media.pyv\t\tpyv\nvideo/vnd.nokia.interleaved-multimedia\t\tnim\nvideo/vnd.nokia.mp4vr\nvideo/vnd.nokia.videovoip\n# mp4: video/mp4\nvideo/vnd.objectvideo\nvideo/vnd.radgamettools.bink\t\t\tbik bk2\nvideo/vnd.radgamettools.smacker\t\t\tsmk\nvideo/vnd.sealed.mpeg1\t\t\t\tsmpg s11\n# smpg: video/vnd.sealed.mpeg1\nvideo/vnd.sealed.mpeg4\t\t\t\ts14\nvideo/vnd.sealed.swf\t\t\t\tsswf ssw\nvideo/vnd.sealedmedia.softseal.mov\t\tsmov smo s1q\n# uvu, uvvu: video/vnd.dece.mp4\nvideo/vnd.uvvu.mp4\nvideo/vnd.youtube.yt\t\t\t\tyt\nvideo/vnd.vivo\t\t\t\t\tviv\nvideo/VP8\nvideo/VP9\n\n# Non-IANA types\n\napplication/mac-compactpro\t\t\tcpt\napplication/metalink+xml\t\t\tmetalink\napplication/owl+xml\t\t\t\towx\napplication/rss+xml\t\t\t\trss\napplication/vnd.android.package-archive\t\tapk\napplication/vnd.oma.dd+xml\t\t\tdd\napplication/vnd.oma.drm.content\t\t\tdcf\n# odf: application/vnd.oasis.opendocument.formula\napplication/vnd.oma.drm.dcf\t\t\to4a o4v\napplication/vnd.oma.drm.message\t\t\tdm\napplication/vnd.oma.drm.rights+wbxml\t\tdrc\napplication/vnd.oma.drm.rights+xml\t\tdr\napplication/vnd.sun.xml.calc\t\t\tsxc\napplication/vnd.sun.xml.calc.template\t\tstc\napplication/vnd.sun.xml.draw\t\t\tsxd\napplication/vnd.sun.xml.draw.template\t\tstd\napplication/vnd.sun.xml.impress\t\t\tsxi\napplication/vnd.sun.xml.impress.template\tsti\napplication/vnd.sun.xml.math\t\t\tsxm\napplication/vnd.sun.xml.writer\t\t\tsxw\napplication/vnd.sun.xml.writer.global\t\tsxg\napplication/vnd.sun.xml.writer.template\t\tstw\napplication/vnd.symbian.install\t\t\tsis\napplication/vnd.wap.mms-message\t\t\tmms\napplication/x-annodex\t\t\t\tanx\napplication/x-bcpio\t\t\t\tbcpio\napplication/x-bittorrent\t\t\ttorrent\napplication/x-bzip2\t\t\t\tbz2\napplication/x-cdlink\t\t\t\tvcd\napplication/x-chrome-extension\t\t\tcrx\napplication/x-cpio\t\t\t\tcpio\napplication/x-csh\t\t\t\tcsh\napplication/x-director\t\t\t\tdcr dir dxr\napplication/x-dvi\t\t\t\tdvi\napplication/x-futuresplash\t\t\tspl\napplication/x-gtar\t\t\t\tgtar\napplication/x-hdf\t\t\t\thdf\napplication/x-java-jnlp-file\t\t\tjnlp\napplication/x-java-pack200\t\t\tpack\napplication/x-killustrator\t\t\tkil\napplication/x-latex\t\t\t\tlatex\napplication/x-netcdf\t\t\t\tnc cdf\napplication/x-perl\t\t\t\tpl\napplication/x-rpm\t\t\t\trpm\napplication/x-sh\t\t\t\tsh\napplication/x-shar\t\t\t\tshar\napplication/x-stuffit\t\t\t\tsit\napplication/x-sv4cpio\t\t\t\tsv4cpio\napplication/x-sv4crc\t\t\t\tsv4crc\napplication/x-tar\t\t\t\ttar\napplication/x-tcl\t\t\t\ttcl\napplication/x-tex\t\t\t\ttex\napplication/x-texinfo\t\t\t\ttexinfo texi\napplication/x-troff-man\t\t\t\tman 1 2 3 4 5 6 7 8\napplication/x-troff-me\t\t\t\tme\napplication/x-troff-ms\t\t\t\tms\napplication/x-ustar\t\t\t\tustar\napplication/x-wais-source\t\t\tsrc\napplication/x-xpinstall\t\t\t\txpi\napplication/x-xspf+xml\t\t\t\txspf\napplication/x-xz\t\t\t\txz\naudio/midi\t\t\t\t\tmid midi kar\naudio/x-aiff\t\t\t\t\taif aiff aifc\naudio/x-annodex\t\t\t\t\taxa\naudio/x-flac\t\t\t\t\tflac\naudio/x-matroska\t\t\t\tmka\naudio/x-mod\t\t\t\t\tmod ult uni m15 mtm 669 med\naudio/x-mpegurl\t\t\t\t\tm3u\naudio/x-ms-wax\t\t\t\t\twax\naudio/x-ms-wma\t\t\t\t\twma\naudio/x-pn-realaudio\t\t\t\tram rm\naudio/x-realaudio\t\t\t\tra\naudio/x-s3m\t\t\t\t\ts3m\naudio/x-stm\t\t\t\t\tstm\naudio/x-wav\t\t\t\t\twav\nchemical/x-xyz\t\t\t\t\txyz\nimage/webp\t\t\t\t\twebp\nimage/x-cmu-raster\t\t\t\tras\nimage/x-portable-anymap\t\t\t\tpnm\nimage/x-portable-bitmap\t\t\t\tpbm\nimage/x-portable-graymap\t\t\tpgm\nimage/x-portable-pixmap\t\t\t\tppm\nimage/x-rgb\t\t\t\t\trgb\nimage/x-targa\t\t\t\t\ttga\nimage/x-xbitmap\t\t\t\t\txbm\nimage/x-xpixmap\t\t\t\t\txpm\nimage/x-xwindowdump\t\t\t\txwd\ntext/html-sandboxed\t\t\t\tsandboxed\ntext/x-pod\t\t\t\t\tpod\ntext/x-setext\t\t\t\t\tetx\nvideo/webm\t\t\t\t\twebm\nvideo/x-annodex\t\t\t\t\taxv\nvideo/x-flv\t\t\t\t\tflv\nvideo/x-javafx\t\t\t\t\tfxm\nvideo/x-matroska\t\t\t\tmkv\nvideo/x-matroska-3d\t\t\t\tmk3d\nvideo/x-ms-asf\t\t\t\t\tasx\nvideo/x-ms-wm\t\t\t\t\twm\nvideo/x-ms-wmv\t\t\t\t\twmv\nvideo/x-ms-wmx\t\t\t\t\twmx\nvideo/x-ms-wvx\t\t\t\t\twvx\nvideo/x-msvideo\t\t\t\t\tavi\nvideo/x-sgi-movie\t\t\t\tmovie\nx-conference/x-cooltalk\t\t\t\tice\nx-epoc/x-sisx-app\t\t\t\tsisx\n"
  },
  {
    "path": "tests/var/pgp/corrupt-pub.asc",
    "content": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxsBNBGbo3ycBCACjECe63GpZanFYLE678OIhzpvY0J6pCBCZblGxXOuwuv7VPIq7\nXQN91UdOL8huDb/JYRxarDoS6+JvyempbzZt0S+veXDl4pRFb+Q4a5sp1l/Mduw0\nrDFErwfF8SpMPBI2WJUhN9n72UEqXVDvTPdcAka8v8p7dS6/RKZzch8P+EjgJME0\np9+N/2Lrbi2nDDXD+xD4Odw83J5V8Xn/jie3GCxtXda5XIX3EzTSB30elLLNBMcq\nooooooooooooooooooooooooooooo6bRS9TT34rZZk7qyB2iroq9SdIBCGyn1q4r\nuIskVNCsqgP9cMa+S1XePUT77VNNN1yzhACpABEBAAHNIUNocmlzIENhcm9uIDxs\nZWFkMmdvbGRAZ21haWwuooooooooooooooooooooooooooooogILCQIVCAIWAgIe\nARYhBEHHWtq4Kh8dGraFnkmZAD9B29oPAAoJEEmZAD9B29oPAawIAImCijTdvDl8\nSibwo7gL4ooooooooooooooooooooooooooooofjiEEW8gVQ4W2KDs74aCGkQtQJ\nirvNA7WnuyMyXZyvhYa63U7GTk5RdVkMygT0a5n8/8HVAenZrBL6VNaZYw/LlgWd\n0knhsmqdGTsjKuYdZ3Cooooooooooooooooooooooooooooo2GWBnvOQje+lQGIf\nrE6TIwsf4QoKXSkTakzggbpZZl2hg2O6dJiij1cH+DYFVTaVXw4rVmo8ckTJ9DiF\nT9H/EmsNqlSKTTv1Aw4raCFZ+T/Ocsw/vIOoEtVhiT/mfDcIbi0VB3EhYvI3eFso\nyiZsjyu9xY0=\n=ZY2q\n-----END PGP PUBLIC KEY BLOCK-----\n"
  },
  {
    "path": "tests/var/pgp/valid-pub.asc",
    "content": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxsBNBGbo3ycBCACjECe63GpZanFYLE678OIhzpvY0J6pCBCZblGxXOuwuv7VPIq7\nXQN91UdOL8huDb/JYRxarDoS6+JvyempbzZt0S+veXDl4pRFb+Q4a5sp1l/Mduw0\nrDFErwfF8SpMPBI2WJUhN9n72UEqXVDvTPdcAka8v8p7dS6/RKZzch8P+EjgJME0\np9+N/2Lrbi2nDDXD+xD4Odw83J5V8Xn/jie3GCxtXda5XIX3EzTSB30elLLNBMcq\nN5xJBTrjhciDzU85Gb+bUecnoj9Oj6bRS9TT34rZZk7qyB2iroq9SdIBCGyn1q4r\nuIskVNCsqgP9cMa+S1XePUT77VNNN1yzhACpABEBAAHNIUNocmlzIENhcm9uIDxs\nZWFkMmdvbGRAZ21haWwuY29tPsLAggQTAQgALAUCZujfJwIbBgILCQIVCAIWAgIe\nARYhBEHHWtq4Kh8dGraFnkmZAD9B29oPAAoJEEmZAD9B29oPAawIAImCijTdvDl8\nSibwo7gL4ayF4S3KhaKCYORcMM1oe4pesy5ME6fjiEEW8gVQ4W2KDs74aCGkQtQJ\nirvNA7WnuyMyXZyvhYa63U7GTk5RdVkMygT0a5n8/8HVAenZrBL6VNaZYw/LlgWd\n0knhsmqdGTsjKuYdZ3CHED85pv/MOwe0pyGOQKtJ1t9qwc6l2GWBnvOQje+lQGIf\nrE6TIwsf4QoKXSkTakzggbpZZl2hg2O6dJiij1cH+DYFVTaVXw4rVmo8ckTJ9DiF\nT9H/EmsNqlSKTTv1Aw4raCFZ+T/Ocsw/vIOoEtVhiT/mfDcIbi0VB3EhYvI3eFso\nyiZsjyu9xY0=\n=ZY2q\n-----END PGP PUBLIC KEY BLOCK-----\n"
  },
  {
    "path": "tox.ini",
    "content": "[global]\nalwayscopy = True\n\n[gh]\nuse_discover = False\n\n[tox]\nenvlist =\n    clean\n    validate\n    i18n\n    compile\n    minimal\n    release\n\nminversion = 4.0\nrequires = virtualenv>=20.0.0\nisolated_build = True\n\n[testenv]\nskip_install = false\nusedevelop = true\nchangedir = {toxinidir}\nallowlist_externals = *\nensurepip = true\nsetenv =\n    COVERAGE_RCFILE = {toxinidir}/pyproject.toml\n\n[testenv:build-src-rpm]\ndescription = Build source RPM and place .src.rpm in dist/\nskip_install = true\nallowlist_externals =\n    bash\n    cp\n    mkdir\n    find\n    rpmbuild\nsetenv =\n    HOME = {envtmpdir}\n    RPMTOP = {envtmpdir}/rpmbuild\ncommands_pre =\n    mkdir -p {envtmpdir}/rpmbuild/SOURCES\n    mkdir -p {envtmpdir}/rpmbuild/SPECS\n    mkdir -p dist/rpm\n    cp packaging/man/apprise.1 {envtmpdir}/rpmbuild/SOURCES/\n    cp packaging/redhat/python-apprise.spec {envtmpdir}/rpmbuild/SPECS/\n    find packaging/redhat/ -iname '*.patch' -exec cp {} {envtmpdir}/rpmbuild/SOURCES/ \\;\n    bash -c 'cp dist/*.tar.gz {envtmpdir}/rpmbuild/SOURCES/'\n    bash -c 'echo \"%_topdir {envtmpdir}/rpmbuild\" > {envtmpdir}/.rpmmacros'\ncommands =\n    rpmbuild --nodeps -bs {envtmpdir}/rpmbuild/SPECS/python-apprise.spec\n    find {envtmpdir}/rpmbuild/SRPMS -name '*.src.rpm' -exec mv {} dist/rpm \\;\n\n[testenv:build-el9-rpm]\ndescription = Run RPM packaging for EPEL9 via Docker\nskip_install = true\nallowlist_externals =\n    docker\ncommands =\n    docker compose run --rm rpmbuild.el9 bash /apprise/bin/build-rpm.sh\n\n[testenv:build-el10-rpm]\ndescription = Run RPM packaging for EPEL10 via Docker\nskip_install = true\nallowlist_externals =\n    docker\ncommands =\n    docker compose run --rm rpmbuild.el10 bash /apprise/bin/build-rpm.sh\n\n[testenv:build-f42-rpm]\ndescription = Run RPM packaging for Fedora 42 via Docker\nskip_install = true\nallowlist_externals =\n    docker\ncommands =\n    docker compose run --rm rpmbuild.f42 bash /apprise/bin/build-rpm.sh\n\n[testenv:build-rawhide-rpm]\ndescription = Run RPM packaging for Fedora Rawhide via Docker\nskip_install = true\nallowlist_externals =\n    docker\ncommands =\n    docker compose run --rm rpmbuild.rawhide bash /apprise/bin/build-rpm.sh\n\n[testenv:lint]\ndescription = Run static analysis using Ruff\ndeps = ruff>=0.14.11\ncommands = ruff check . {posargs}\n\n[testenv:format]\ndescription = Auto-format code using Ruff\ndeps = ruff>=0.14.11\ncommands = ruff check . --fix {posargs}\n\n# - This defines a CLI script entry point for packaging.\n# - It ensures that pip install . or python -m build includes\n#    a working apprise command.\n# - This is required for proper packaging and end-user usage\n[project.scripts]\napprise = \"apprise.cli:main\"\n\n[testenv:shell]\ndescription = Obtain a shell with the apprise library in it's path\nextras = dev,all-plugins\ncommands =\n    pip install --no-cache-dir -e \".[dev,all-plugins]\"\n    python {posargs}\n\n[testenv:apprise]\ndescription = Run Apprise CLI with args\nextras = all-plugins\ncommands =\n    apprise {posargs}\n\n[testenv:cli]\ndescription = Sanity-check Apprise CLI invocation\ncommands =\n    apprise --version\n\n[testenv:checkdone]\ndescription = Lint + full test run to ensure PR readiness\nextras = dev,all-plugins\ncommands =\n    pip install --no-cache-dir -e \".[dev,all-plugins]\"\n    ruff check .\n    coverage run --source=apprise -m pytest tests {posargs}\n    coverage report\n\n[testenv:qa]\ndescription = Full tests with all plugins + code coverage\nextras = dev,all-plugins\ncommands =\n    pip install --no-cache-dir -e \".[dev,all-plugins]\"\n    coverage erase\n    coverage run --source=apprise -m pytest tests {posargs}\n    coverage report -m\n    coverage xml -o coverage.xml\n\n[testenv:minimal]\ndescription = Minimal dependencies + code coverage\nextras = dev\ncommands =\n    pip install --no-cache-dir -e \".[dev]\"\n    coverage erase\n    coverage run --source=apprise -m pytest tests {posargs}\n    coverage report -m\n    coverage xml -o coverage.xml\n\n[testenv:test]\ndescription = Run simplified tests without coverage\nextras = dev,all-plugins\ncommands =\n    pip install --no-cache-dir -e \".[dev,all-plugins]\"\n    pytest --tb=short -q {posargs}\n\n[testenv:clean]\ndescription = Remove build artifacts and cache files\nskip_install = true\nallowlist_externals =\n    find\n    rm\ncommands =\n    find . -type f -name \"*.pyc\" -delete\n    find . -type f -name \"*.pyo\" -delete\n    find . -type f -name \"*.orig\" -delete\n    find ./apprise/i18n -type f -name \"*.mo\" -delete\n    rm -rf BUILD SOURCES SRPMS BUILDROOT .cache .ruff_cache .coverage-reports .coverage coverage.xml dist build apprise.egg-info .mypy_cache .pytest_cache  \"__pycache__\"\n    find . -type d -name \"__pycache__\" -delete\n\n[testenv:i18n]\nplatform = linux|darwin\ndescription = Extract and update .pot/.po files for translation\nextras = dev\ndeps = Babel\nensurepip = true\ncommands =\n    mkdir -p apprise/i18n\n    pybabel extract -F babel.cfg -o apprise/i18n/apprise.pot apprise\n    pybabel update --domain=apprise -i apprise/i18n/apprise.pot -d apprise/i18n\n    sh packaging/i18n_normalize.sh\n\n[testenv:compile]\ndescription = Compile .mo files\ndeps = Babel\ncommands =\n    pybabel compile --domain=apprise -d apprise/i18n\n\n[testenv:build]\ndescription = Build sdist and wheel (assumes translations compiled)\ndeps =\n\tbuild\ncommands =\n    python -m build\n\n[testenv:build-sdist]\ndeps = build\ncommands = python -m build --sdist\n\n[testenv:build-wheel]\ndeps = build\ncommands = python -m build --wheel\n\n[testenv:validate]\ndescription = Validate pyproject.toml against PEP 621/508\ndeps =\n   rpmlint\n\tvalidate-pyproject\n\tpackaging\ncommands =\n\tvalidate-pyproject pyproject.toml\n   rpmlint packaging/redhat/python-apprise.spec\n\n[testenv:twine-check]\ndescription = Run twine check on dist artifacts\ndeps = twine\ncommands =\n    python -m twine check dist/*.whl dist/*.tar.gz\n\n[testenv:man]\ndescription = Rebuild the Apprise man page\nallowlist_externals = docker\ncommands =\n    docker compose run --rm rpmbuild.el10 ronn \\\n      --organization=\"Chris Caron <lead2gold@gmail.com>\" \\\n      packaging/man/apprise.md\n\n[testenv:release]\ndescription = Prepare translations, compile, and build all artifacts\ndeps =\n    validate-pyproject\n    packaging\n    Babel\n    build\n    twine\ncommands =\n    tox -e clean\n    tox -e validate\n    tox -e i18n\n    tox -e compile\n    tox -e man\n    tox -e build\n    tox -e build-src-rpm\n    tox -e twine-check\n"
  },
  {
    "path": "win-requirements.txt",
    "content": "#\n# Note: This file is being kept for backwards compatibility with\n#       legacy systems that point here.  All future changes should\n#       occur in pyproject.toml.  Contents of this file can be found\n#       in [project.optional-dependencies].windows\npywin32\ntzdata\n"
  }
]