Repository: caronc/apprise Branch: master Commit: aa9988b174dd Files: 431 Total size: 5.8 MB Directory structure: gitextract_a_8zomhz/ ├── .codecov.yml ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1_bug_report.md │ │ ├── 2_enhancement_request.md │ │ ├── 3_new-notification-request.md │ │ ├── 4_question.md │ │ └── config.yaml │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── codeql-analysis.yml │ ├── lint.yml │ ├── loc-badge.yml │ ├── pkgbuild.yml │ └── tests.yml ├── .gitignore ├── .vscode/ │ └── settings.json ├── ACKNOWLEDGEMENTS.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── SECURITY.md ├── all-plugin-requirements.txt ├── apprise/ │ ├── __init__.py │ ├── apprise.py │ ├── apprise_attachment.py │ ├── apprise_config.py │ ├── asset.py │ ├── assets/ │ │ ├── NotifyXML-1.0.xsd │ │ └── NotifyXML-1.1.xsd │ ├── attachment/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── file.py │ │ ├── http.py │ │ └── memory.py │ ├── cli.py │ ├── common.py │ ├── compat.py │ ├── config/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── file.py │ │ ├── http.py │ │ └── memory.py │ ├── conversion.py │ ├── decorators/ │ │ ├── __init__.py │ │ ├── base.py │ │ └── notify.py │ ├── emojis.py │ ├── exception.py │ ├── i18n/ │ │ ├── __init__.py │ │ └── en/ │ │ └── LC_MESSAGES/ │ │ └── apprise.po │ ├── locale.py │ ├── logger.py │ ├── manager.py │ ├── manager_attachment.py │ ├── manager_config.py │ ├── manager_plugins.py │ ├── persistent_store.py │ ├── plugins/ │ │ ├── __init__.py │ │ ├── africas_talking.py │ │ ├── apprise_api.py │ │ ├── aprs.py │ │ ├── bark.py │ │ ├── base.py │ │ ├── bluesky.py │ │ ├── brevo.py │ │ ├── bulksms.py │ │ ├── bulkvs.py │ │ ├── burstsms.py │ │ ├── chanify.py │ │ ├── clickatell.py │ │ ├── clicksend.py │ │ ├── custom_form.py │ │ ├── custom_json.py │ │ ├── custom_xml.py │ │ ├── d7networks.py │ │ ├── dapnet.py │ │ ├── dbus.py │ │ ├── dingtalk.py │ │ ├── discord.py │ │ ├── dot.py │ │ ├── email/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── common.py │ │ │ └── templates.py │ │ ├── emby.py │ │ ├── enigma2.py │ │ ├── fcm/ │ │ │ ├── __init__.py │ │ │ ├── color.py │ │ │ ├── common.py │ │ │ ├── oauth.py │ │ │ └── priority.py │ │ ├── feishu.py │ │ ├── flock.py │ │ ├── fluxer.py │ │ ├── fortysixelks.py │ │ ├── freemobile.py │ │ ├── glib.py │ │ ├── gnome.py │ │ ├── google_chat.py │ │ ├── gotify.py │ │ ├── growl.py │ │ ├── guilded.py │ │ ├── home_assistant.py │ │ ├── httpsms.py │ │ ├── ifttt.py │ │ ├── irc/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── client.py │ │ │ ├── protocol.py │ │ │ ├── state.py │ │ │ └── templates.py │ │ ├── jellyfin.py │ │ ├── join.py │ │ ├── kavenegar.py │ │ ├── kodi.py │ │ ├── kumulos.py │ │ ├── lametric.py │ │ ├── lark.py │ │ ├── line.py │ │ ├── macosx.py │ │ ├── mailgun.py │ │ ├── mastodon.py │ │ ├── matrix.py │ │ ├── mattermost.py │ │ ├── messagebird.py │ │ ├── misskey.py │ │ ├── mqtt.py │ │ ├── msg91.py │ │ ├── msteams.py │ │ ├── nextcloud.py │ │ ├── nextcloudtalk.py │ │ ├── notica.py │ │ ├── notifiarr.py │ │ ├── notificationapi.py │ │ ├── notifico.py │ │ ├── ntfy.py │ │ ├── office365.py │ │ ├── one_signal.py │ │ ├── opsgenie.py │ │ ├── pagerduty.py │ │ ├── pagertree.py │ │ ├── parseplatform.py │ │ ├── plivo.py │ │ ├── popcorn_notify.py │ │ ├── prowl.py │ │ ├── pushbullet.py │ │ ├── pushdeer.py │ │ ├── pushed.py │ │ ├── pushjet.py │ │ ├── pushme.py │ │ ├── pushover.py │ │ ├── pushplus.py │ │ ├── pushsafer.py │ │ ├── pushy.py │ │ ├── qq.py │ │ ├── reddit.py │ │ ├── resend.py │ │ ├── revolt.py │ │ ├── rocketchat.py │ │ ├── rsyslog.py │ │ ├── ryver.py │ │ ├── sendgrid.py │ │ ├── sendpulse.py │ │ ├── serverchan.py │ │ ├── ses.py │ │ ├── seven.py │ │ ├── sfr.py │ │ ├── signal_api.py │ │ ├── signl4.py │ │ ├── simplepush.py │ │ ├── sinch.py │ │ ├── slack.py │ │ ├── smpp.py │ │ ├── smseagle.py │ │ ├── smsmanager.py │ │ ├── smtp2go.py │ │ ├── sns.py │ │ ├── sparkpost.py │ │ ├── spike.py │ │ ├── splunk.py │ │ ├── spugpush.py │ │ ├── streamlabs.py │ │ ├── synology.py │ │ ├── syslog.py │ │ ├── techuluspush.py │ │ ├── telegram.py │ │ ├── threema.py │ │ ├── twilio.py │ │ ├── twist.py │ │ ├── twitter.py │ │ ├── vapid/ │ │ │ ├── __init__.py │ │ │ └── subscription.py │ │ ├── viber.py │ │ ├── voipms.py │ │ ├── vonage.py │ │ ├── webexteams.py │ │ ├── wecombot.py │ │ ├── whatsapp.py │ │ ├── windows.py │ │ ├── workflows.py │ │ ├── wxpusher.py │ │ ├── xmpp/ │ │ │ ├── __init__.py │ │ │ ├── adapter.py │ │ │ ├── base.py │ │ │ └── common.py │ │ └── zulip.py │ ├── py.typed │ ├── url.py │ └── utils/ │ ├── __init__.py │ ├── base64.py │ ├── cwe312.py │ ├── disk.py │ ├── format.py │ ├── json.py │ ├── logic.py │ ├── module.py │ ├── parse.py │ ├── pem.py │ ├── pgp.py │ ├── sanitize.py │ ├── singleton.py │ ├── socket.py │ ├── templates.py │ └── time.py ├── babel.cfg ├── bin/ │ ├── README.md │ ├── apprise │ ├── build-rpm.sh │ ├── checkdone.sh │ └── test.sh ├── dev-requirements.txt ├── docker-compose.yml ├── packaging/ │ ├── README.md │ ├── i18n_normalize.sh │ ├── man/ │ │ ├── apprise.1 │ │ ├── apprise.1.html │ │ └── apprise.md │ └── redhat/ │ ├── python-apprise.rpmlintrc.el10 │ ├── python-apprise.rpmlintrc.el9 │ └── python-apprise.spec ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests/ │ ├── conftest.py │ ├── docker/ │ │ ├── Dockerfile.el10 │ │ ├── Dockerfile.el9 │ │ ├── Dockerfile.f42 │ │ ├── Dockerfile.py310 │ │ ├── Dockerfile.py311 │ │ ├── Dockerfile.py312 │ │ ├── Dockerfile.py39 │ │ └── Dockerfile.rawhide │ ├── helpers/ │ │ ├── __init__.py │ │ ├── asyncio.py │ │ ├── environment.py │ │ ├── module.py │ │ └── rest.py │ ├── test_api.py │ ├── test_apprise_asset.py │ ├── test_apprise_attachments.py │ ├── test_apprise_cli.py │ ├── test_apprise_config.py │ ├── test_apprise_emojis.py │ ├── test_apprise_helpers.py │ ├── test_apprise_jsonencoder.py │ ├── test_apprise_pickle.py │ ├── test_apprise_translations.py │ ├── test_apprise_utils.py │ ├── test_asyncio.py │ ├── test_attach_base.py │ ├── test_attach_file.py │ ├── test_attach_http.py │ ├── test_attach_memory.py │ ├── test_compat_py39.py │ ├── test_config_base.py │ ├── test_config_file.py │ ├── test_config_http.py │ ├── test_config_memory.py │ ├── test_conversion.py │ ├── test_decorator_notify.py │ ├── test_escapes.py │ ├── test_logger.py │ ├── test_notification_manager.py │ ├── test_notify_base.py │ ├── test_persistent_store.py │ ├── test_plugin_africas_talking.py │ ├── test_plugin_apprise_api.py │ ├── test_plugin_aprs.py │ ├── test_plugin_bark.py │ ├── test_plugin_base_formatting.py │ ├── test_plugin_bluesky.py │ ├── test_plugin_brevo.py │ ├── test_plugin_bulksms.py │ ├── test_plugin_bulkvs.py │ ├── test_plugin_burstsms.py │ ├── test_plugin_chanify.py │ ├── test_plugin_clickatell.py │ ├── test_plugin_clicksend.py │ ├── test_plugin_custom_form.py │ ├── test_plugin_custom_json.py │ ├── test_plugin_custom_xml.py │ ├── test_plugin_d7networks.py │ ├── test_plugin_dapnet.py │ ├── test_plugin_dbus.py │ ├── test_plugin_dingtalk.py │ ├── test_plugin_discord.py │ ├── test_plugin_dot.py │ ├── test_plugin_email.py │ ├── test_plugin_emby.py │ ├── test_plugin_enigma2.py │ ├── test_plugin_fcm.py │ ├── test_plugin_feishu.py │ ├── test_plugin_flock.py │ ├── test_plugin_fluxer.py │ ├── test_plugin_fortysixelks.py │ ├── test_plugin_freemobile.py │ ├── test_plugin_glib.py │ ├── test_plugin_gnome.py │ ├── test_plugin_google_chat.py │ ├── test_plugin_gotify.py │ ├── test_plugin_growl.py │ ├── test_plugin_guilded.py │ ├── test_plugin_homeassistant.py │ ├── test_plugin_httpsms.py │ ├── test_plugin_ifttt.py │ ├── test_plugin_irc.py │ ├── test_plugin_irc_state.py │ ├── test_plugin_jellyfin.py │ ├── test_plugin_join.py │ ├── test_plugin_kavenegar.py │ ├── test_plugin_kumulos.py │ ├── test_plugin_lametric.py │ ├── test_plugin_lark.py │ ├── test_plugin_line.py │ ├── test_plugin_macosx.py │ ├── test_plugin_mailgun.py │ ├── test_plugin_mastodon.py │ ├── test_plugin_matrix.py │ ├── test_plugin_mattermost.py │ ├── test_plugin_messagebird.py │ ├── test_plugin_misskey.py │ ├── test_plugin_mqtt.py │ ├── test_plugin_msg91.py │ ├── test_plugin_msteams.py │ ├── test_plugin_nextcloud.py │ ├── test_plugin_nextcloudtalk.py │ ├── test_plugin_notica.py │ ├── test_plugin_notifiarr.py │ ├── test_plugin_notificationapi.py │ ├── test_plugin_notifico.py │ ├── test_plugin_ntfy.py │ ├── test_plugin_office365.py │ ├── test_plugin_onesignal.py │ ├── test_plugin_opsgenie.py │ ├── test_plugin_pagerduty.py │ ├── test_plugin_pagertree.py │ ├── test_plugin_parse_platform.py │ ├── test_plugin_plivo.py │ ├── test_plugin_popcorn_notify.py │ ├── test_plugin_prowl.py │ ├── test_plugin_pushbullet.py │ ├── test_plugin_pushdeer.py │ ├── test_plugin_pushed.py │ ├── test_plugin_pushjet.py │ ├── test_plugin_pushme.py │ ├── test_plugin_pushover.py │ ├── test_plugin_pushplus.py │ ├── test_plugin_pushsafer.py │ ├── test_plugin_pushy.py │ ├── test_plugin_qq.py │ ├── test_plugin_reddit.py │ ├── test_plugin_resend.py │ ├── test_plugin_revolt.py │ ├── test_plugin_rocket_chat.py │ ├── test_plugin_rsyslog.py │ ├── test_plugin_ryver.py │ ├── test_plugin_sendgrid.py │ ├── test_plugin_sendpulse.py │ ├── test_plugin_serverchan.py │ ├── test_plugin_ses.py │ ├── test_plugin_seven.py │ ├── test_plugin_sfr.py │ ├── test_plugin_signal.py │ ├── test_plugin_signl4.py │ ├── test_plugin_simplepush.py │ ├── test_plugin_sinch.py │ ├── test_plugin_slack.py │ ├── test_plugin_smpp.py │ ├── test_plugin_sms_manager.py │ ├── test_plugin_smseagle.py │ ├── test_plugin_smtp2go.py │ ├── test_plugin_sns.py │ ├── test_plugin_sparkpost.py │ ├── test_plugin_spike.py │ ├── test_plugin_splunk.py │ ├── test_plugin_spugpush.py │ ├── test_plugin_streamlabs.py │ ├── test_plugin_synology.py │ ├── test_plugin_syslog.py │ ├── test_plugin_techululs_push.py │ ├── test_plugin_telegram.py │ ├── test_plugin_threema.py │ ├── test_plugin_title_maxlen.py │ ├── test_plugin_twilio.py │ ├── test_plugin_twist.py │ ├── test_plugin_twitter.py │ ├── test_plugin_vapid.py │ ├── test_plugin_viber.py │ ├── test_plugin_voipms.py │ ├── test_plugin_vonage.py │ ├── test_plugin_webex_teams.py │ ├── test_plugin_wecombot.py │ ├── test_plugin_whatsapp.py │ ├── test_plugin_windows.py │ ├── test_plugin_workflows.py │ ├── test_plugin_wxpusher.py │ ├── test_plugin_xbmc_kodi.py │ ├── test_plugin_xmpp.py │ ├── test_plugin_zulip.py │ ├── test_utils_format.py │ ├── test_utils_pem.py │ ├── test_utils_sanitize.py │ ├── test_utils_socket.py │ └── var/ │ ├── 01_test_example.html │ ├── fcm/ │ │ ├── service_account-bad-type.json │ │ └── service_account.json │ ├── mime.types │ └── pgp/ │ ├── corrupt-pub.asc │ └── valid-pub.asc ├── tox.ini └── win-requirements.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codecov.yml ================================================ codecov: require_ci_to_pass: false comment: layout: "diff, flags, files" behavior: default require_changes: false # if true: only post the comment if coverage changes coverage: status: project: default: target: auto threshold: 1% patch: default: target: auto threshold: 1% ================================================ FILE: .github/FUNDING.yml ================================================ github: caronc custom: - 'https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E' ================================================ FILE: .github/ISSUE_TEMPLATE/1_bug_report.md ================================================ --- name: 🐛 Bug Report about: Report errors and problems in Apprise title: '' labels: 'bug' assignees: '' --- ## Notification Service(s) Impacted ## What happened ## Apprise URL(s) involved (redact secrets) ## Steps to reproduce 1. 2. 3. ## Environment - Apprise version: - Python version: - OS and distribution: - Install method: - If using Docker: image/tag: ## Logs (redact secrets) ```text paste logs here ``` ================================================ FILE: .github/ISSUE_TEMPLATE/2_enhancement_request.md ================================================ --- name: 💡 Enhancement Request about: Suggest an improvement to Apprise title: '' labels: 'enhancement' assignees: '' --- ## The idea ## Use-case ## Proposed change ## Compatibility impact - Would this be a breaking change? Yes / No - If yes, describe what breaks and any migration path. ## Alternatives considered ## Documentation impact - Does appriseit.com require updates for this change? Yes / No - If yes, please also open (or link) a documentation ticket/PR in apprise-docs. ## Additional context ================================================ FILE: .github/ISSUE_TEMPLATE/3_new-notification-request.md ================================================ --- name: 📣 New Notification Request about: Request a new notification service integration title: '' labels: ['enhancement', 'new-notification'] assignees: '' --- ## What is the name of the service? ## Proposed Apprise schema (service id) ## Proposed Appriseit service slug ## Provide details that help development - Homepage: - Official API docs: - Authentication method: - Rate limits (if known): - Message limits (if known): - Attachments supported: Yes / No / Unknown ## Example payload or curl snippet (optional) ```text paste example here ``` ## Anything else? ## ☝️ Documentation note If this integration is accepted, it must also include an apprise-docs update so the service page exists on appriseit.com. If you can contribute docs, open a ticket or PR in: https://github.com/caronc/apprise-docs ================================================ FILE: .github/ISSUE_TEMPLATE/4_question.md ================================================ --- name: ❓ Support Question about: Ask a question about Apprise (prefer Discussions) title: '' labels: 'question' assignees: '' --- ## Please use Discussions for support questions https://github.com/caronc/apprise/discussions If you are filing an issue anyway, include: ## Question ## Apprise version and environment - Apprise version: - Python version: - OS and distribution: - Install method: ================================================ FILE: .github/ISSUE_TEMPLATE/config.yaml ================================================ blank_issues_enabled: false contact_links: - name: Documentation (Apprise Docs) url: https://appriseit.com about: Documentation - name: Documentation Source (Apprise Docs) url: https://github.com/caronc/apprise-docs about: Documentation updates, fixes, and translation work belong here. - name: Support and Questions url: https://github.com/caronc/apprise/discussions about: Please use Discussions for questions and general support. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Description **Related issue (if applicable):** # ## Checklist * [ ] Documentation ticket created (if applicable):** [apprise-docs/##](https://github.com/caronc/apprise-docs/issue/) * [ ] The change is tested and works locally. * [ ] No commented-out code in this PR. * [ ] No lint errors (use `tox -e lint` and optionally `tox -e format`). * [ ] Test coverage added or updated (use `tox -e minimal`). ## Testing Anyone can help test as follows: ```bash # Create a virtual environment python3 -m venv apprise # Change into our new directory cd apprise # Activate our virtual environment source bin/activate # Install the branch pip install git+https://github.com/caronc/apprise.git@ # If you have cloned the branch and have tox available to you: tox -e apprise -- -t "Test Title" -b "Test Message" \ ``` ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ name: "CodeQL" on: push: branches: [ master ] pull_request: branches: [ master ] schedule: - cron: '42 15 * * 5' # Cancel in-progress jobs when pushing to the same branch. concurrency: cancel-in-progress: true group: ${{ github.workflow }}-${{ github.ref }} jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] steps: - name: Checkout repository uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Autobuild uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 ================================================ FILE: .github/workflows/lint.yml ================================================ # .github/workflows/lint.yml name: Run Lint Checks on: push: paths: - '**.py' pull_request: paths: - '**.py' jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install tox run: python -m pip install tox - name: Run Ruff lint check run: tox -e lint ================================================ FILE: .github/workflows/loc-badge.yml ================================================ # LoC = Lines of Code name: LoC Badge on: # Manual only workflow_dispatch: permissions: contents: write pull-requests: write jobs: loc-badge: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Generate LoC badge (Apprise library only) uses: alexispurslane/GHA-LoC-Badge@v2.0.0 id: badge with: debug: true # Run from the root of the repo so the pattern paths are exact directory: ./ badge: .github/badges/loc.svg patterns: "apprise/**/*.py" ignore: "__pycache__" - name: Print the output run: | echo "Scanned: ${{ steps.badge.outputs.counted_files }}"; echo "Line Count: ${{ steps.badge.outputs.total_lines }}"; - name: Create PR (if changed) uses: peter-evans/create-pull-request@v7 with: commit-message: "Update Lines of Code (LoC) badge" title: "Update LoC badge" body: "Automated update of the Lines of Code badge." branch: "automation/loc-badge" delete-branch: true add-paths: ".github/badges/loc.svg" ================================================ FILE: .github/workflows/pkgbuild.yml ================================================ # # Verify on CI/GHA that RPM package building works. # name: RPM Packaging on: push: branches: [main, master, 'release/**'] pull_request: branches: [main, master] workflow_dispatch: jobs: build: name: Build RPMs (${{ matrix.dist }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: dist: [el9, el10] container: image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }} options: --user root steps: - name: Checkout source uses: actions/checkout@v4 - name: Build RPMs run: ./bin/build-rpm.sh env: APPRISE_DIR: ${{ github.workspace }} # Drop RPMs into dist/ inside the workspace DIST_DIR: ${{ github.workspace }}/dist/${{ matrix.dist }} - name: Show RPMs found for upload run: | echo "Listing dist/${{ matrix.dist }}/**/*.rpm:" find dist/${{ matrix.dist }} -type f -name '*.rpm' - name: Upload RPM Artifacts uses: actions/upload-artifact@v4 with: name: built-rpms-${{ matrix.dist }} path: ./dist/${{ matrix.dist }} if-no-files-found: error retention-days: 5 - name: Upload rpmlint config files uses: actions/upload-artifact@v4 with: # Upload the specific files needed for verification name: rpmlint-config-${{ matrix.dist }} path: ./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }} retention-days: 5 verify: name: Verify RPMs (${{ matrix.dist }}) needs: build runs-on: ubuntu-latest strategy: fail-fast: false matrix: dist: [el9, el10] container: image: ghcr.io/caronc/apprise-rpmbuild:${{ matrix.dist }} options: --user root steps: - name: Download built RPMs uses: actions/download-artifact@v4 with: name: built-rpms-${{ matrix.dist }} path: ./dist - name: Download rpmlint config files uses: actions/download-artifact@v4 with: name: rpmlint-config-${{ matrix.dist }} # Download files directly into the correct directory path: ./packaging/redhat - name: Lint RPMs run: | set -e RC_FILE="./packaging/redhat/python-apprise.rpmlintrc.${{ matrix.dist }}" if rpmlint --help 2>&1 | grep -q -- '--rpmlintrc'; then echo "Using rpmlint --rpmlintrc with $RC_FILE" rpmlint --rpmlintrc "$RC_FILE" ./dist/**/*.rpm else echo "Using rpmlint v1.x on older distribution" rpmlint -f "$RC_FILE" ./dist/**/*.rpm fi - name: Install and verify RPMs run: | echo "Installing RPMs from: ./dist/" find ./dist -name '*.rpm' dnf install -y ./dist/**/*.rpm apprise --version - name: Check Installed Files run: rpm -qlp ./dist/**/*.rpm ================================================ FILE: .github/workflows/tests.yml ================================================ name: Run Tests on: # Run tests on push to main, master, or any release/ branch push: branches: [main, master, 'release/**'] # Always test on pull requests targeting main/master pull_request: branches: [main, master] # Allow manual triggering via GitHub UI workflow_dispatch: jobs: test: name: Python ${{ matrix.python-version }} – ${{ matrix.tox_env }} on ${{ matrix.os }} runs-on: ${{ matrix.os || 'ubuntu-latest' }} strategy: fail-fast: false # Let all jobs run, even if one fails matrix: include: - python-version: "3.9" tox_env: qa - python-version: "3.10" tox_env: qa - python-version: "3.11" tox_env: qa - python-version: "3.12" tox_env: qa # Pre-release testing (won't fail entire workflow if this fails) - python-version: "3.13-dev" tox_env: qa continue-on-error: true - python-version: "3.14-dev" tox_env: qa continue-on-error: true - python-version: "3.15-dev" tox_env: qa continue-on-error: true # Platform validation only (one version) - os: windows-latest python-version: "3.12" tox_env: qa - os: macos-latest python-version: "3.12" tox_env: qa # Minimal test run on latest Python only # this verifies Apprise still works when extra libraries are not available - python-version: "3.12" tox_env: minimal steps: - uses: actions/checkout@v4 # Install tox for isolated environment and plugin test orchestration - name: Install tox run: python -m pip install tox # Run tox with the specified environment (qa, minimal, etc.) - name: Run tox for ${{ matrix.tox_env }} run: tox -e ${{ matrix.tox_env }} - name: Upload coverage report if: always() uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.tox_env }} path: .coverage include-hidden-files: true codecov: name: Upload merged coverage to Codecov runs-on: ubuntu-latest needs: test # Waits for all matrix jobs to complete if: always() # Even if a test fails, still attempt to upload what we have steps: - uses: actions/checkout@v4 - name: Download all coverage reports uses: actions/download-artifact@v4 with: path: coverage-artifacts - name: Combine and generate coverage run: | pip install coverage # Create a consistent temp dir mkdir -p coverage-inputs # Copy and rename each coverage file to .coverage.job_name i=0 for f in $(find coverage-artifacts -name .coverage); do cp "$f" "coverage-inputs/.coverage.$i" i=$((i+1)) done # Confirm files staged ls -alh coverage-inputs # Combine them all coverage combine coverage-inputs coverage report coverage xml -o coverage.xml # Upload merged coverage results to Codecov for visualization - name: Upload to Codecov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} file: coverage.xml verbose: false # Used for debugging only fail_ci_if_error: false # Avoid failing job if Codecov is down env: CODECOV_PR: ${{ github.event.pull_request.number }} CODECOV_SHA: ${{ github.sha }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # left-over-files from conflicts/merges *.orig # C extensions *.so # vi swap files .*.sw? # Distribution / packaging .Python env/ .venv* build/ BUILD/ BUILDROOT/ SOURCES/ SRPMS/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib64/ parts/ sdist/ *.egg-info/ .installed.cfg *.egg .local # Generated from Docker Instance .bash_history .python_history # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ #Ipython Notebook .ipynb_checkpoints #PyCharm .idea #PyDev (Eclipse) .project .pydevproject .settings # Others .DS_Store ================================================ FILE: .vscode/settings.json ================================================ { "python.testing.pytestArgs": [ "tests" ], "python.linting.enabled": true, "python.linting.ruffEnabled": true, "python.linting.ruffPath": "ruff", "python.formatting.provider": "none", "editor.formatOnSave": true, "python.testing.cwd": "${workspaceFolder}", "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.envFile": "${workspaceFolder}/.env", "terminal.integrated.env.linux": { "PYTHONPATH": "${workspaceFolder}" } } ================================================ FILE: ACKNOWLEDGEMENTS.md ================================================ # Contributions to the Apprise Project ## Creator & Maintainer * Chris Caron ## Contributors The following users have contributed to this project and their deserved recognition has been identified here. If you have contributed and wish to be acknowledged for it, the syntax is as follows: ``` * [Your name or handle] <[email or website]> * [Month Year] - [Brief summary of your contribution] ``` The contributors have been listed in chronological order: * Wim de With * Dec 2018 - Added Matrix Support * Hitesh Sondhi * Mar 2019 - Added Flock Support * Andreas Motl * Mar 2020 - Fix XMPP Support * Oct 2022 - Drop support for Python 2 * Oct 2022 - Add support for Python 3.11 * Oct 2022 - Improve efficiency of NotifyEmail * Joey Espinosa <@particledecay> * Apr 3rd 2022 - Added Ntfy Support * Kate Ward * 6th Feb 2024 - Add Revolt Support * Han Wang * Apr 2024 - Refactored test cases * Toni Wells <@isometimescode> * May 2024 - Fixed token length with apprise:// ================================================ FILE: CONTRIBUTING.md ================================================ # 🤝 Contributing to Apprise Thank you for your interest in contributing to Apprise! We welcome bug reports, feature requests, documentation improvements, and new notification plugins. Please follow the guidelines below to help us review and merge your contributions smoothly. --- ## ✅ Quick Checklist Before You Submit - ✔️ Your code passes all lint checks: ```bash tox -e lint ``` - ✔️ Your changes are covered by tests: ```bash tox -e qa ``` - ✔️ Your code is clean and consistently formatted: ```bash tox -e format ``` - ✔️ You followed the plugin template (if adding a new plugin). - ✔️ You included inline docstrings and respected the BSD 2-Clause license. - ✔️ Your commit message is descriptive. --- ## 📦 Local Development Setup To get started with development: ### 🧰 System Requirements - Python >= 3.9 - `pip` - `git` - Optional: `VS Code` with the Python extension ### 🚀 One-Time Setup ```bash git clone https://github.com/caronc/apprise.git cd apprise # Install all runtime + dev dependencies pip install .[dev] ``` (Optional, but recommended if actively developing): ```bash pip install -e .[dev] ``` --- ## 🧪 Running Tests ```bash pytest # Run all tests pytest tests/foo.py # Run a specific test file ``` Run with coverage: ```bash pytest --cov=apprise --cov-report=term ``` --- ## 🧹 Linting & Formatting with ruff ```bash ruff check apprise tests # Check linting ruff check apprise tests --fix # Auto-fix ruff format apprise tests # Format files ``` --- ## 🧰 Optional: Using VS Code 1. Open the repo: `code .` 2. Press `Ctrl+Shift+P → Python: Select Interpreter` 3. Choose the same interpreter you used for `pip install .[dev]` 4. Press `Ctrl+Shift+P → Python: Discover Tests` `.vscode/settings.json` is pre-configured with: - pytest as the test runner - ruff for linting - PYTHONPATH set to project root No `.venv` is required unless you choose to use one. --- ## 📌 How to Contribute 1. **Fork the repository** and create a new branch. 2. Make your changes. 3. Run the checks listed above. 4. Submit a pull request (PR) to the `main` branch. GitHub Actions will run tests and lint checks on your PR automatically. --- ## 🧪 Need Help with Testing or Plugins? See [DEVELOPMENT.md](./DEVELOPMENT.md) for: - Full setup instructions - Tox environment descriptions - RPM testing - Plugin development guidance --- ## 🙏 Thank You Your contributions make Apprise better for everyone — thank you! 📝 See [ACKNOWLEDGEMENTS.md](./ACKNOWLEDGEMENTS.md) for a list of contributors. ================================================ FILE: LICENSE ================================================ BSD 2-Clause License Copyright (c) 2026, Chris Caron All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: MANIFEST.in ================================================ include LICENSE include README.md include CONTRIBUTING.md include ACKNOWLEDGEMENTS.md include SECURITY.md include pyproject.toml include tox.ini include babel.cfg include requirements.txt include win-requirements.txt include dev-requirements.txt include all-plugin-requirements.txt include apprise/py.typed recursive-include tests * recursive-include packaging * recursive-include apprise/i18n *.pot recursive-include apprise/i18n *.po recursive-include apprise/i18n */LC_MESSAGES/*.po global-exclude *.pyc global-exclude *.pyo global-exclude __pycache__ ================================================ FILE: README.md ================================================ ![Apprise Logo](https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png)
**ap·prise** / *verb*
To inform or tell (someone). To make one aware of something.
*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. * One notification library to rule them all. * A common and intuitive notification syntax. * Supports the handling of images and attachments (_to the notification services that will accept them_). * It's incredibly lightweight. * Amazing response times because all messages sent asynchronously. Developers 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. System 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. [![Paypal](https://img.shields.io/badge/paypal-donate-green.svg)](https://www.paypal.com/donate/?hosted_button_id=CR6YF7KLQWQ5E) [![Follow](https://img.shields.io/twitter/follow/l2gnux)](https://twitter.com/l2gnux/)
[![Discord](https://img.shields.io/discord/558793703356104724.svg?colorB=7289DA&label=Discord&logo=Discord&logoColor=7289DA&style=flat-square)](https://discord.gg/MMPeN2D) [![Python](https://img.shields.io/pypi/pyversions/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/) [![Build Status](https://github.com/caronc/apprise/actions/workflows/tests.yml/badge.svg)](https://github.com/caronc/apprise/actions/workflows/tests.yml) [![Lines of Code](https://raw.githubusercontent.com/caronc/apprise/master/.github/badges/loc.svg)](https://github.com/caronc/apprise/actions/workflows/loc-badge.yml) [![CodeCov Status](https://codecov.io/github/caronc/apprise/branch/master/graph/badge.svg)](https://codecov.io/github/caronc/apprise) [![PyPi Downloads](https://img.shields.io/pepy/dt/apprise.svg?style=flat-square)](https://pypi.org/project/apprise/) # Table of Contents * [Supported Notifications](#supported-notifications) * [Productivity Based Notifications](#productivity-based-notifications) * [SMS Notifications](#sms-notifications) * [Desktop Notifications](#desktop-notifications) * [Email Notifications](#email-notifications) * [Custom Notifications](#custom-notifications) * [Installation](#installation) * [Command Line Usage](#command-line-usage) * [Configuration Files](#cli-configuration-files) * [File Attachments](#cli-file-attachments) * [Loading Custom Notifications/Hooks](#cli-loading-custom-notificationshooks) * [Environment Variables](#cli-environment-variables) * [Developer API Usage](#developer-api-usage) * [Configuration Files](#api-configuration-files) * [File Attachments](#api-file-attachments) * [Loading Custom Notifications/Hooks](#api-loading-custom-notificationshooks) * [Persistent Storage](#persistent-storage) * [More Supported Links and Documentation](#want-to-learn-more) Visit the [Official Documentation](https://appriseit.com/getting-started/) site for more information on Apprise. # Supported Notifications The 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/). ## Productivity Based Notifications The 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. | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Apprise API](https://appriseit.com/services/apprise_api/) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token | [AWS SES](https://appriseit.com/services/ses/) | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName
ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN | [Bark](https://appriseit.com/services/bark/) | bark:// | (TCP) 80 or 443 | bark://hostname
bark://hostname/device_key
bark://hostname/device_key1/device_key2/device_keyN
barks://hostname
barks://hostname/device_key
barks://hostname/device_key1/device_key2/device_keyN | [BlueSky](https://appriseit.com/services/bluesky/) | bluesky:// | (TCP) 443 | bluesky://Handle:AppPw
bluesky://Handle:AppPw/TargetHandle
bluesky://Handle:AppPw/TargetHandle1/TargetHandle2/TargetHandleN | [Brevo](https://appriseit.com/services/brevo/) | brevo:// | (TCP) 443 | brevo://APIToken:FromEmail/
brevo://APIToken:FromEmail/ToEmail
brevo://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Chanify](https://appriseit.com/services/chanify/) | chantify:// | (TCP) 443 | chantify://token | [Discord](https://appriseit.com/services/discord/) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Dot.](https://appriseit.com/services/dot/) | dot:// | (TCP) 443 | dot://apikey@device_id/text/
dot://apikey@device_id/image/
**Note**: `device_id` is the Quote/0 hardware serial | [Emby](https://appriseit.com/services/emby/) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname | [Enigma2](https://appriseit.com/services/enigma2/) | enigma2:// or enigma2s:// | (TCP) 80 or 443 | enigma2://hostname | [FCM](https://appriseit.com/services/fcm/) | fcm:// | (TCP) 443 | fcm://project@apikey/DEVICE_ID
fcm://project@apikey/#TOPIC
fcm://project@apikey/DEVICE_ID1/#topic1/#topic2/DEVICE_ID2/ | [Feishu](https://appriseit.com/services/feishu/) | feishu:// | (TCP) 443 | feishu://token | [Flock](https://appriseit.com/services/flock/) | flock:// | (TCP) 443 | flock://token
flock://botname@token
flock://app_token/u:userid
flock://app_token/g:channel_id
flock://app_token/u:userid/g:channel_id | [Google Chat](https://appriseit.com/services/googlechat/) | gchat:// | (TCP) 443 | gchat://workspace/key/token | [Gotify](https://appriseit.com/services/gotify/) | gotify:// or gotifys:// | (TCP) 80 or 443 | gotify://hostname/token
gotifys://hostname/token?priority=high | [Growl](https://appriseit.com/services/growl/) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port
**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 | [Guilded](https://appriseit.com/services/guilded/) | guilded:// | (TCP) 443 | guilded://webhook_id/webhook_token
guilded://avatar@webhook_id/webhook_token | [Home Assistant](https://appriseit.com/services/homeassistant/) | hassio:// or hassios:// | (TCP) 8123 or 443 | hassio://hostname/accesstoken
hassio://user@hostname/accesstoken
hassio://user:password@hostname:port/accesstoken
hassio://hostname/optional/path/accesstoken | [IFTTT](https://appriseit.com/services/ifttt/) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1 | [IRC](https://appriseit.com/services/irc/) | irc:// or ircs:// | (TCP) 6667 or 6697 | ircs://user:pass@irc.server/@user
ircs://user:pass@irc.server/#channel?join=true&mode=nickserv
ircs://user:pass@znc.server/@user1/@user2/@user3/#channel1 | [Jellyfin](https://appriseit.com/services/jellyfin/) | jellyfin:// or jellyfins:// | (TCP) 8096 | jellyfin://user@hostname/
jellyfins://user:password@hostname | [Join](https://appriseit.com/services/join/) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://appriseit.com/services/kodi/) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Kumulos](https://appriseit.com/services/kumulos/) | kumulos:// | (TCP) 443 | kumulos://apikey/serverkey | [LaMetric Time](https://appriseit.com/services/lametric/) | lametric:// | (TCP) 443 | lametric://apikey@device_ipaddr
lametric://apikey@hostname:port
lametric://client_id@client_secret | [Lark](https://appriseit.com/services/lark/) | lark:// | (TCP) 443 | lark://BotToken | [Line](https://appriseit.com/services/line/) | line:// | (TCP) 443 | line://Token@User
line://Token/User1/User2/UserN | [Mailgun](https://appriseit.com/services/mailgun/) | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey
mailgun://user@hostname/apikey/email
mailgun://user@hostname/apikey/email1/email2/emailN
mailgun://user@hostname/apikey/?name="From%20User" | [Mastodon](https://appriseit.com/services/mastodon/) | mastodon:// or mastodons://| (TCP) 80 or 443 | mastodon://access_key@hostname
mastodon://access_key@hostname/@user
mastodon://access_key@hostname/@user1/@user2/@userN | [Matrix](https://appriseit.com/services/matrix/) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname
matrix://user@hostname
matrixs://user:pass@hostname:port/#room_alias
matrixs://user:pass@hostname:port/!room_id
matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2
matrixs://token@hostname:port/?webhook=matrix
matrix://user:token@hostname/?webhook=slack&format=markdown | [Mattermost](https://appriseit.com/services/mattermost/) | mmost:// or mmosts:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
| [Microsoft Power Automate / Workflows (MSTeams)](https://appriseit.com/services/workflows/) | workflows:// | (TCP) 443 | workflows://WorkflowID/Signature/ | [Microsoft Teams](https://appriseit.com/services/msteams/) | msteams:// | (TCP) 443 | msteams://TokenA/TokenB/TokenC/ | [Misskey](https://appriseit.com/services/misskey/) | misskey:// or misskeys://| (TCP) 80 or 443 | misskey://access_token@hostname | [MQTT](https://appriseit.com/services/mqtt/) | mqtt:// or mqtts:// | (TCP) 1883 or 8883 | mqtt://hostname/topic
mqtt://user@hostname/topic
mqtts://user:pass@hostname:9883/topic | [Nextcloud](https://appriseit.com/services/nextcloud/) | ncloud:// or nclouds:// | (TCP) 80 or 443 | ncloud://adminuser:pass@host/User
nclouds://adminuser:pass@host/User1/User2/UserN | [NextcloudTalk](https://appriseit.com/services/nextcloudtalk/) | nctalk:// or nctalks:// | (TCP) 80 or 443 | nctalk://user:pass@host/RoomId
nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN | [Notica](https://appriseit.com/services/notica/) | notica:// | (TCP) 443 | notica://Token/ | [NotificationAPI](https://appriseit.com/services/notificationapi/) | napi:// | (TCP) 443 | napi://ClientID/ClientSecret/Target
napi://ClientID/ClientSecret/Target1/Target2/TargetN
napi://MessageType@ClientID/ClientSecret/Target | [Notifiarr](https://appriseit.com/services/notifiarr/) | notifiarr:// | (TCP) 443 | notifiarr://apikey/#channel
notifiarr://apikey/#channel1/#channel2/#channeln | [Notifico](https://appriseit.com/services/notifico/) | notifico:// | (TCP) 443 | notifico://ProjectID/MessageHook/ | [ntfy](https://appriseit.com/services/ntfy/) | ntfy:// | (TCP) 80 or 443 | ntfy://topic/
ntfys://topic/ | [Office 365](https://appriseit.com/services/office365/) | o365:// | (TCP) 443 | o365://TenantID:AccountEmail/ClientID/ClientSecret
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN | [OneSignal](https://appriseit.com/services/onesignal/) | onesignal:// | (TCP) 443 | onesignal://AppID@APIKey/PlayerID
onesignal://TemplateID:AppID@APIKey/UserID
onesignal://AppID@APIKey/#IncludeSegment
onesignal://AppID@APIKey/Email | [Opsgenie](https://appriseit.com/services/opsgenie/) | opsgenie:// | (TCP) 443 | opsgenie://APIKey
opsgenie://APIKey/UserID
opsgenie://APIKey/#Team
opsgenie://APIKey/\*Schedule
opsgenie://APIKey/^Escalation | [PagerDuty](https://appriseit.com/services/pagerduty/) | pagerduty:// | (TCP) 443 | pagerduty://IntegrationKey@ApiKey
pagerduty://IntegrationKey@ApiKey/Source/Component | [PagerTree](https://appriseit.com/services/pagertree/) | pagertree:// | (TCP) 443 | pagertree://integration_id | [ParsePlatform](https://appriseit.com/services/parseplatform/) | parsep:// or parseps:// | (TCP) 80 or 443 | parsep://AppID:MasterKey@Hostname
parseps://AppID:MasterKey@Hostname | [PopcornNotify](https://appriseit.com/services/popcornnotify/) | popcorn:// | (TCP) 443 | popcorn://ApiKey/ToPhoneNo
popcorn://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
popcorn://ApiKey/ToEmail
popcorn://ApiKey/ToEmail1/ToEmail2/ToEmailN/
popcorn://ApiKey/ToPhoneNo1/ToEmail1/ToPhoneNoN/ToEmailN | [Prowl](https://appriseit.com/services/prowl/) | prowl:// | (TCP) 443 | prowl://apikey
prowl://apikey/providerkey | [PushBullet](https://appriseit.com/services/pushbullet/) | pbul:// | (TCP) 443 | pbul://accesstoken
pbul://accesstoken/#channel
pbul://accesstoken/A_DEVICE_ID
pbul://accesstoken/email@address.com
pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE | [Pushjet](https://appriseit.com/services/pushjet/) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret
pjet://hostname:port/secret
pjets://secret@hostname/secret
pjets://hostname:port/secret | [Push (Techulus)](https://appriseit.com/services/techulus/) | push:// | (TCP) 443 | push://apikey/ | [Pushed](https://appriseit.com/services/pushed/) | pushed:// | (TCP) 443 | pushed://appkey/appsecret/
pushed://appkey/appsecret/#ChannelAlias
pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN
pushed://appkey/appsecret/@UserPushedID
pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN | [PushMe](https://appriseit.com/services/pushme/) | pushme:// | (TCP) 443 | pushme://Token/ | [Pushover](https://appriseit.com/services/pushover/) | pover:// | (TCP) 443 | pover://user@token
pover://user@token/DEVICE
pover://user@token/DEVICE1/DEVICE2/DEVICEN
**Note**: you must specify both your user_id and token | [Pushplus](https://appriseit.com/services/pushplus/) | pushplus:// | (TCP) 443 | pushplus://Token | [PushSafer](https://appriseit.com/services/pushsafer/) | psafer:// or psafers:// | (TCP) 80 or 443 | psafer://privatekey
psafers://privatekey/DEVICE
psafer://privatekey/DEVICE1/DEVICE2/DEVICEN | [Pushy](https://appriseit.com/services/pushy/) | pushy:// | (TCP) 443 | pushy://apikey/DEVICE
pushy://apikey/DEVICE1/DEVICE2/DEVICEN
pushy://apikey/TOPIC
pushy://apikey/TOPIC1/TOPIC2/TOPICN | [PushDeer](https://appriseit.com/services/pushdeer/) | pushdeer:// or pushdeers:// | (TCP) 80 or 443 | pushdeer://pushKey
pushdeer://hostname/pushKey
pushdeer://hostname:port/pushKey | [QQ Push](https://appriseit.com/services/qq/) | qq:// | (TCP) 443 | qq://Token | [Reddit](https://appriseit.com/services/reddit/) | reddit:// | (TCP) 443 | reddit://user:password@app_id/app_secret/subreddit
reddit://user:password@app_id/app_secret/sub1/sub2/subN | [Resend](https://appriseit.com/services/resend/) | resend:// | (TCP) 443 | resend://APIToken:FromEmail/
resend://APIToken:FromEmail/ToEmail
resend://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [Revolt](https://appriseit.com/services/revolt/) | revolt:// | (TCP) 443 | revolt://bottoken/ChannelID
revolt://bottoken/ChannelID1/ChannelID2/ChannelIDN | | [Rocket.Chat](https://appriseit.com/services/rocketchat/) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/#Channel1/#Channel1/RoomID
rocket://user:password@hostname/#Channel
rocket://webhook@hostname
rockets://webhook@hostname/@User/#Channel | [RSyslog](https://appriseit.com/services/rsyslog/) | rsyslog:// | (UDP) 514 | rsyslog://hostname
rsyslog://hostname/Facility | [Ryver](https://appriseit.com/services/ryver/) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token | [SendGrid](https://appriseit.com/services/sendgrid/) | sendgrid:// | (TCP) 443 | sendgrid://APIToken:FromEmail/
sendgrid://APIToken:FromEmail/ToEmail
sendgrid://APIToken:FromEmail/ToEmail1/ToEmail2/ToEmailN/ | [SendPulse](https://appriseit.com/services/sendpulse/) | sendpulse:// | (TCP) 443 | sendpulse://user@host/ClientId/ClientSecret
sendpulse://user@host/ClientId/clientSecret/ToEmail
sendpulse://user@host/ClientId/ClientSecret/ToEmail1/ToEmail2/ToEmailN/ | [ServerChan](https://appriseit.com/services/serverchan/) | schan:// | (TCP) 443 | schan://sendkey/ | [Signal API](https://appriseit.com/services/signal/) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SIGNL4](https://appriseit.com/services/signl4/) | signl4:// | (TCP) 80 or 443 | signl4://hostname | [SimplePush](https://appriseit.com/services/simplepush/) | spush:// | (TCP) 443 | spush://apikey
spush://salt:password@apikey
spush://apikey?event=Apprise | [Slack](https://appriseit.com/services/slack/) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [SMTP2Go](https://appriseit.com/services/smtp2go/) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey
smtp2go://user@hostname/apikey/email
smtp2go://user@hostname/apikey/email1/email2/emailN
smtp2go://user@hostname/apikey/?name="From%20User" | [SparkPost](https://appriseit.com/services/sparkpost/) | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey
sparkpost://user@hostname/apikey/email
sparkpost://user@hostname/apikey/email1/email2/emailN
sparkpost://user@hostname/apikey/?name="From%20User" | [Spike.sh](https://appriseit.com/services/spike/) | spike:// | (TCP) 443 | spike://Token | [Splunk](https://appriseit.com/services/splunk/) | splunk:// or victorops:/ | (TCP) 443 | splunk://route_key@apikey
splunk://route_key@apikey/entity_id | [Spug Push](https://appriseit.com/services/spugpush/) | spugpush:// | (TCP) 443 | spugpush://Token | [Streamlabs](https://appriseit.com/services/streamlabs/) | strmlabs:// | (TCP) 443 | strmlabs://AccessToken/
strmlabs://AccessToken/?name=name&identifier=identifier&amount=0¤cy=USD | [Synology Chat](https://appriseit.com/services/synology_chat/) | synology:// or synologys:// | (TCP) 80 or 443 | synology://hostname/token
synology://hostname:port/token | [Syslog](https://appriseit.com/services/syslog/) | syslog:// | n/a | syslog://
syslog://Facility | [Telegram](https://appriseit.com/services/telegram/) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Twitter](https://appriseit.com/services/twitter/) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret
twitter://user@CKey/CSecret/AKey/ASecret
twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2
twitter://CKey/CSecret/AKey/ASecret?mode=tweet | [Twist](https://appriseit.com/services/twist/) | twist:// | (TCP) 443 | twist://pasword:login
twist://password:login/#channel
twist://password:login/#team:channel
twist://password:login/#team:channel1/channel2/#team3:channel | [Vapid (WebPush)](https://appriseit.com/services/vapid/) | vapid:// | (TCP) 443 | vapid://subscriber/target
vapid://subscriber/target?subfile=path&keyfile=path | [Viber](https://appriseit.com/services/viber/) | viber:// | (TCP) 443 | viber://token/target | [Webex Teams (Cisco)](https://appriseit.com/services/wxteams/) | wxteams:// | (TCP) 443 | wxteams://Token | [WeCom Bot](https://appriseit.com/services/wecombot/) | wecombot:// | (TCP) 443 | wecombot://BotKey | [WhatsApp](https://appriseit.com/services/whatsapp/) | whatsapp:// | (TCP) 443 | whatsapp://AccessToken@FromPhoneID/ToPhoneNo
whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo | [WxPusher](https://appriseit.com/services/wxpusher/) | wxpusher:// | (TCP) 443 | wxpusher://AppToken@UserID1/UserID2/UserIDN
wxpusher://AppToken@Topic1/Topic2/Topic3
wxpusher://AppToken@UserID1/Topic1/ | [XBMC](https://appriseit.com/services/xbmc/) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [XMPP](https://appriseit.com/services/xmpp/) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://user:pass@hostname
xmpps://user:pass@hostname/jid
xmpps://user:pass@hostname/jid1/jid2@example.ca | [Zulip Chat](https://appriseit.com/services/zulip/) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Stream
zulip://botname@Organization/Token/Email ## SMS Notifications SMS 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. | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [46elks](https://appriseit.com/services/46elks/) | 46elks:// | (TCP) 443 | 46elks://user:password@FromPhoneNo
46elks://user:password@FromPhoneNo/ToPhoneNo
46elks://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Africas Talking](https://appriseit.com/services/africas_talking/) | atalk:// | (TCP) 443 | atalk://AppUser@ApiKey/ToPhoneNo
atalk://AppUser@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Automated Packet Reporting System (ARPS)](https://appriseit.com/services/aprs/) | aprs:// | (TCP) 10152 | aprs://user:pass@callsign
aprs://user:pass@callsign1/callsign2/callsignN | [AWS SNS](https://appriseit.com/services/sns/) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN | [BulkSMS](https://appriseit.com/services/bulksms/) | bulksms:// | (TCP) 443 | bulksms://user:password@ToPhoneNo
bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [BulkVS](https://appriseit.com/services/bulkvs/) | bulkvs:// | (TCP) 443 | bulkvs://user:password@FromPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Burst SMS](https://appriseit.com/services/burstsms/) | burstsms:// | (TCP) 443 | burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Clickatell](https://appriseit.com/services/clickatell/) | clickatell:// | (TCP) 443 | clickatell://ApiKey/ToPhoneNo
clickatell://FromPhoneNo@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [ClickSend](https://appriseit.com/services/clicksend/) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DAPNET](https://appriseit.com/services/dapnet/) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign
dapnet://user:pass@callsign1/callsign2/callsignN | [D7 Networks](https://appriseit.com/services/d7networks/) | d7sms:// | (TCP) 443 | d7sms://token@PhoneNo
d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DingTalk](https://appriseit.com/services/dingtalk/) | dingtalk:// | (TCP) 443 | dingtalk://token/
dingtalk://token/ToPhoneNo
dingtalk://token/ToPhoneNo1/ToPhoneNo2/ToPhoneNo1/ | [Free-Mobile](https://appriseit.com/services/freemobile/) | freemobile:// | (TCP) 443 | freemobile://user@password/ | [httpSMS](https://appriseit.com/services/httpsms/) | httpsms:// | (TCP) 443 | httpsms://ApiKey@FromPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Kavenegar](https://appriseit.com/services/kavenegar/) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [MessageBird](https://appriseit.com/services/messagebird/) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [MSG91](https://appriseit.com/services/msg91/) | msg91:// | (TCP) 443 | msg91://TemplateID@AuthKey/ToPhoneNo
msg91://TemplateID@AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Plivo](https://appriseit.com/services/plivo/) | plivo:// | (TCP) 443 | plivo://AuthID@Token@FromPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Seven](https://appriseit.com/services/seven/) | seven:// | (TCP) 443 | seven://ApiKey/FromPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Société Française du Radiotéléphone (SFR)](https://appriseit.com/services/sfr/) | sfr:// | (TCP) 443 | sfr://user:password>@spaceId/ToPhoneNo
sfr://user:password>@spaceId/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signal API](https://appriseit.com/services/signal/) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://appriseit.com/services/sinch/) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SMPP](https://appriseit.com/services/smpp/) | smpp:// or smpps:// | (TCP) 443 | smpp://user:password@hostname:port/FromPhoneNo/ToPhoneNo
smpps://user:password@hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [SMSEagle](https://appriseit.com/services/smseagle/) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo
smseagles://hostname:port/@ToContact
smseagles://hostname:port/#ToGroup
smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/ | [SMS Manager](https://appriseit.com/services/sms_manager/) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Threema Gateway](https://appriseit.com/services/threema/) | threema:// | (TCP) 443 | threema://GatewayID@secret/ToPhoneNo
threema://GatewayID@secret/ToEmail
threema://GatewayID@secret/ToThreemaID/
threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/... | [Twilio](https://appriseit.com/services/twilio/) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?method=call
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN?method=call | [Voipms](https://appriseit.com/services/voipms/) | voipms:// | (TCP) 443 | voipms://password:email/FromPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Vonage](https://appriseit.com/services/vonage/) (formerly Nexmo) | vonage:// | (TCP) 443 | vonage://ApiKey:ApiSecret@FromPhoneNo
vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
vonage://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ ## Desktop Notifications | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Linux DBus Notifications](https://appriseit.com/services/dbus/) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// | [Linux Gnome Notifications](https://appriseit.com/services/gnome/) | gnome:// | n/a | gnome:// | [MacOS X Notifications](https://appriseit.com/services/macosx/) | macosx:// | n/a | macosx:// | [Windows Notifications](https://appriseit.com/services/windows/) | windows:// | n/a | windows:// ## Email Notifications | Service ID | Default Port | Example Syntax | | ---------- | ------------ | -------------- | | [mailto://](https://appriseit.com/services/email/) | (TCP) 25 | mailto://userid:pass@domain.com
mailto://domain.com?user=userid&pass=password
mailto://domain.com:2525?user=userid&pass=password
mailto://user@gmail.com&pass=password
mailto://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com
mailto://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply | [mailtos://](https://appriseit.com/services/email/) | (TCP) 587 | mailtos://userid:pass@domain.com
mailtos://domain.com?user=userid&pass=password
mailtos://domain.com:465?user=userid&pass=password
mailtos://user@hotmail.com&pass=password
mailtos://mySendingUsername:mySendingPassword@example.com?to=receivingAddress@example.com
mailtos://userid:password@example.com?smtp=mail.example.com&from=noreply@example.com&name=no%20reply Apprise 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/). ## Custom Notifications | Post Method | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [Form](https://appriseit.com/services/form/) | form:// or forms:// | (TCP) 80 or 443 | form://hostname
form://user@hostname
form://user:password@hostname:port
form://hostname/a/path/to/post/to | [JSON](https://appriseit.com/services/json/) | json:// or jsons:// | (TCP) 80 or 443 | json://hostname
json://user@hostname
json://user:password@hostname:port
json://hostname/a/path/to/post/to | [XML](https://appriseit.com/services/xml/) | xml:// or xmls:// | (TCP) 80 or 443 | xml://hostname
xml://user@hostname
xml://user:password@hostname:port
xml://hostname/a/path/to/post/to # Installation The easiest way is to install Apprise from PyPI: ```bash pip install apprise ``` Apprise is also packaged as an RPM and available through [EPEL](https://docs.fedoraproject.org/en-US/epel/) supporting CentOS, Redhat, Rocky, Oracle Linux, etc. ```bash # Follow instructions on https://docs.fedoraproject.org/en-US/epel # to get your system connected up to EPEL and then: # Redhat/CentOS 7.x users yum install apprise # Redhat/Rocky Linux 8.x+ and/or Fedora Users dnf install apprise ``` You 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. # Command Line Usage A 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: ```bash # Send a notification to as many servers as you want # as you can easily chain one after another (the -vv provides some # additional verbosity to help let you know what is going on): apprise -vv -t 'my title' -b 'my notification body' \ 'mailto://myemail:mypass@gmail.com' \ 'pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b' # If you don't specify a --body (-b) then stdin is used allowing # you to use the tool as part of your every day administration: cat /proc/cpuinfo | apprise -vv -t 'cpu info' \ 'mailto://myemail:mypass@gmail.com' # The title field is totally optional uptime | apprise -vv \ 'discord:///4174216298/JHMHI8qBe7bk2ZwO5U711o3dV_js' ``` ## CLI Configuration Files No 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/). ```bash # By default if no url or configuration is specified apprise will attempt to load # configuration files (if present) from: # ~/.apprise # ~/.apprise.yaml # ~/.config/apprise.conf # ~/.config/apprise.yaml # /etc/apprise.conf # /etc/apprise.yaml # Also a subdirectory handling allows you to leverage plugins # ~/.apprise/apprise # ~/.apprise/apprise.yaml # ~/.config/apprise/apprise.conf # ~/.config/apprise/apprise.yaml # /etc/apprise/apprise.yaml # /etc/apprise/apprise.conf # Windows users can store their default configuration files here: # %APPDATA%/Apprise/apprise.conf # %APPDATA%/Apprise/apprise.yaml # %LOCALAPPDATA%/Apprise/apprise.conf # %LOCALAPPDATA%/Apprise/apprise.yaml # %ALLUSERSPROFILE%\Apprise\apprise.conf # %ALLUSERSPROFILE%\Apprise\apprise.yaml # %PROGRAMFILES%\Apprise\apprise.conf # %PROGRAMFILES%\Apprise\apprise.yaml # %COMMONPROGRAMFILES%\Apprise\apprise.conf # %COMMONPROGRAMFILES%\Apprise\apprise.yaml # The configuration files specified above can also be identified with a `.yml` # extension or even just entirely removing the `.conf` extension altogether. # If you loaded one of those files, your command line gets really easy: apprise -vv -t 'my title' -b 'my notification body' # If you want to deviate from the default paths or specify more than one, # just specify them using the --config switch: apprise -vv -t 'my title' -b 'my notification body' \ --config=/path/to/my/config.yml # Got lots of configuration locations? No problem, you can specify them all: # Apprise can even fetch the configuration from over a network! apprise -vv -t 'my title' -b 'my notification body' \ --config=/path/to/my/config.yml \ --config=https://localhost/my/apprise/config ``` ## CLI Tagging Support Apprise 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. It is important to understand how Apprise handles multiple tags: * **OR Logic (Union)**: To notify services that have *either* Tag A **OR** Tag B, specify the `-g` switch multiple times. * **AND Logic (Intersection)**: To notify services that have *both* Tag A **AND** Tag B, separate the tags with a comma. ```bash # OR Logic: Notify any service tagged 'devops' OR 'admin' apprise -vv -t "Union Test" \ --config=~/apprise.yml \ -g devops -g admin # AND Logic: Notify only services tagged with BOTH 'devops' AND 'critical' apprise -vv -t "Intersection Test" \ --config=~/apprise.yml \ -g devops,critical ## CLI File Attachments Apprise also supports file attachments too! Specify as many attachments to a notification as you want. ```bash # Send a funny image you found on the internet to a colleague: apprise -vv --title 'Agile Joke' \ --body 'Did you see this one yet?' \ --attach https://i.redd.it/my2t4d2fx0u31.jpg \ 'mailto://myemail:mypass@gmail.com' # Easily send an update from a critical server to your dev team apprise -vv --title 'system crash' \ --body 'I do not think Jim fixed the bug; see attached...' \ --attach /var/log/myprogram.log \ --attach /var/debug/core.2345 \ --tag devteam ``` ## CLI Loading Custom Notifications/Hooks To create your own custom `schema://` hook so that you can trigger your own custom code, simply include the `@notify` decorator to wrap your function. ```python from apprise.decorators import notify # # The below assumes you want to catch foobar:// calls: # @notify(on="foobar", name="My Custom Foobar Plugin") def my_custom_notification_wrapper(body, title, notify_type, *args, **kwargs): """My custom notification function that triggers on all foobar:// calls """ # Write all of your code here... as an example... print("{}: {} - {}".format(notify_type.upper(), title, body)) # Returning True/False is a way to relay your status back to Apprise. # Returning nothing (None by default) is always interpreted as a Success ``` Once you've defined your custom hook, you just need to tell Apprise where it is at runtime. ```bash # By default if no plugin path is specified apprise will attempt to load # all plugin files (if present) from the following directory paths: # ~/.apprise/plugins # ~/.config/apprise/plugins # /var/lib/apprise/plugins # Windows users can store their default plugin files in these directories: # %APPDATA%/Apprise/plugins # %LOCALAPPDATA%/Apprise/plugins # %ALLUSERSPROFILE%\Apprise\plugins # %PROGRAMFILES%\Apprise\plugins # %COMMONPROGRAMFILES%\Apprise\plugins # If you placed your plugin file within one of the directories already defined # above, then your call simply needs to look like: apprise -vv --title 'custom override' \ --body 'the body of my message' \ foobar:\\ # However you can override the path like so apprise -vv --title 'custom override' \ --body 'the body of my message' \ --plugin-path /path/to/my/plugin.py \ foobar:\\ ``` You can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/). ## CLI Environment Variables Those using the Command Line Interface (CLI) can also leverage environment variables to pre-set the default settings: | Variable | Description | |------------------------ | ----------------- | | `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. | `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. | `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. | `APPRISE_STORAGE_PATH` | Explicitly specify the persistent storage path to use (overriding the default). # Developer API Usage To send a notification from within your python application, just do the following: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add all of the notification services by their server url. # A sample email notification: apobj.add('mailto://myuserid:mypass@gmail.com') # A sample pushbullet notification apobj.add('pbul://o.gn5kj6nfhv736I7jC3cj3QLRiyhgl98b') # Then notify these services any time you desire. The below would # notify all of the services loaded into our Apprise object. apobj.notify( body='what a great notification service!', title='my notification title', ) ``` ## API Configuration Files Developers 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/). ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Create an Config instance config = apprise.AppriseConfig() # Add a configuration source: config.add('/path/to/my/config.yml') # Add another... config.add('https://myserver:8080/path/to/config') # Make sure to add our config into our apprise object apobj.add(config) # You can mix and match; add an entry directly if you want too # In this entry we associate the 'admin' tag with our notification apobj.add('mailto://myuser:mypass@hotmail.com', tag='admin') # Then notify these services any time you desire. The below would # notify all of the services that have not been bound to any specific # tag. apobj.notify( body='what a great notification service!', title='my notification title', ) # Tagging allows you to specifically target only specific notification # services you've loaded: apobj.notify( body='send a notification to our admin group', title='Attention Admins', # notify any services tagged with the 'admin' tag tag='admin', ) # If you want to notify absolutely everything (regardless of whether # it's been tagged or not), just use the reserved tag of 'all': apobj.notify( body='send a notification to our admin group', title='Attention Admins', # notify absolutely everything loaded, regardless on whether # it has a tag associated with it or not: tag='all', ) ``` ## API File Attachments Attachments are very easy to send using the Apprise API: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add at least one service you want to notify apobj.add('mailto://myuser:mypass@hotmail.com') # Then send your attachment. apobj.notify( title='A great photo of our family', body='The flash caused Jane to close her eyes! hah! :)', attach='/local/path/to/my/DSC_003.jpg', ) # Send a web based attachment too! In the below example, we connect to a home # security camera and send a live image to an email. By default remote web # content is cached, but for a security camera we might want to call notify # again later in our code, so we want our last image retrieved to expire(in # this case after 3 seconds). apobj.notify( title='Latest security image', attach='http://admin:password@hikvision-cam01/ISAPI/Streaming/channels/101/picture?cache=3' ) ``` To send more than one attachment, just use a list, set, or tuple instead: ```python import apprise # Create an Apprise instance apobj = apprise.Apprise() # Add at least one service you want to notify apobj.add('mailto://myuser:mypass@hotmail.com') # Now add all of the entries we're interested in: attach = ( # ?name= allows us to rename the actual jpeg as found on the site # to be another name when sent to our receipient(s) 'https://i.redd.it/my2t4d2fx0u31.jpg?name=FlyingToMars.jpg', # Now add another: '/path/to/funny/joke.gif', ) # Send your multiple attachments with a single notify call: apobj.notify( title='Some good jokes.', body='Hey guys, check out these!', attach=attach, ) ``` ## API Loading Custom Notifications/Hooks By default, no custom plugins are loaded at all for those building from within the Apprise API. It's at the developers discretion to load custom modules. But should you choose to do so, it's as easy as including the path reference in the `AppriseAsset()` object prior to the initialization of your `Apprise()` instance. For example: ```python from apprise import Apprise from apprise import AppriseAsset # Prepare your Asset object so that you can enable the custom plugins to # be loaded for your instance of Apprise... asset = AppriseAsset(plugin_paths="/path/to/scan") # OR You can also generate scan more then one file too: asset = AppriseAsset( plugin_paths=[ # Iterate over all python libraries found in the root of the # specified path. This is NOT a recursive (directory) scan; only # the first level is parsed. HOWEVER, if a directory containing # an __init__.py is found, it will be included in the load. "/dir/containing/many/python/libraries", # An absolute path to a plugin.py to exclusively load "/path/to/plugin.py", # if you point to a directory that has an __init__.py file found in # it, then only that file is loaded (it's similar to point to a # absolute .py file. Hence, there is no (level 1) scanning at all # within the directory specified. "/path/to/dir/library" ] ) # Now that we've got our asset, we just work with our Apprise object as we # normally do aobj = Apprise(asset=asset) # If our new custom `foobar://` library was loaded (presuming we prepared # one like in the examples above). then you would be able to safely add it # into Apprise at this point aobj.add('foobar://') # Send our notification out through our foobar:// aobj.notify("test") ``` You can read more about creating your own custom notifications and/or hooks [here](https://appriseit.com/library/extending/decorator/). # Persistent Storage Persistent storage allows Apprise to cache re-occurring actions optionaly to disk. This can greatly reduce the overhead used to send a notification. There are 3 Persistent Storage operational states Apprise can operate using: 1. `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. * Developers who choose to use this operational mode can also force cached information manually if they choose. * The CLI will use this operational mode by default. 1. `flush`: Flushes any cache information to the filesystem during every transaction. 1. `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. * 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. ## CLI Persistent Storage Commands You can provide the keyword `storage` on your CLI call to see the persistent storage options available to you. ```bash # List all of the occupied space used by Apprise's Persistent Storage: apprise storage list # list is the default option, so the following does the same thing: apprise storage # You can prune all of your storage older then 30 days # and not accessed for this period like so: apprise storage prune # You can do a hard reset (and wipe all persistent storage) with: apprise storage clean ``` You can also filter your results by adding tags and/or URL Identifiers. When you get a listing (`apprise storage list`), you may see: ``` # example output of 'apprise storage list': 1. f7077a65 0.00B unused - matrixs://abcdef:****@synapse.example12.com/%23general?image=no&mode=off&version=3&msgtype... tags: team 2. 0e873a46 81.10B active - tgram://W...U//?image=False&detect=yes&silent=no&preview=no&content=before&mdv=v1&format=m... tags: personal 3. abcd123 12.00B stale ``` The (persistent storage) cache states are: - `unused`: This plugin has not commited anything to disk for reuse/cache purposes - `active`: This plugin has written content to disk. Or at the very least, it has prepared a persistent storage location it can write into. - `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. You can use this information to filter your results by specifying _URL ID_ (UID) values after your command. For example: ```bash # The below commands continue with the example already identified above # the following would match abcd123 (even though just ab was provided) # The output would only list the 'stale' entry above apprise storage list ab # knowing our filter is safe, we could remove it # the below command would not obstruct our other to URLs and would only # remove our stale one: apprise storage clean ab # Entries can be filtered by tag as well: apprise storage list --tag=team # You can match on multiple URL ID's as well: # The followin would actually match the URL ID's of 1. and .2 above apprise storage list f 0 ``` When 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. For more information on persistent storage, [visit here](https://appriseit.com/cli/persistent-storage/). ## API Persistent Storage Commands For developers, persistent storage is set in the operational mode of `memory` by default. It'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. For example: ```python from apprise import Apprise from apprise import AppriseAsset from apprise import PersistentStoreMode # Prepare a location the persistent storage can write it's cached content to. # By setting this path, this immediately assumes you wish to operate the # persistent storage in the operational 'auto' mode asset = AppriseAsset(storage_path="/path/to/save/data") # If you want to be more explicit and set more options, then you may do the # following asset = AppriseAsset( # Set our storage path directory (minimum requirement to enable it) storage_path="/path/to/save/data", # Set the mode... the options are: # 1. PersistentStoreMode.MEMORY # - disable persistent storage from writing to disk # 2. PersistentStoreMode.AUTO # - write to disk on demand # 3. PersistentStoreMode.FLUSH # - write to disk always and often storage_mode=PersistentStoreMode.FLUSH # The URL IDs are by default 8 characters in length. You can increase and # decrease it's value here. The value must be > 2. The default value is 8 # if not otherwise specified storage_idlen=8, ) # Now that we've got our asset, we just work with our Apprise object as we # normally do aobj = Apprise(asset=asset) ``` For more information on persistent storage, [visit here](https://appriseit.com/library/persistent-storage/). # Want To Learn More? If you're interested in reading more about this and other methods on how to customize your own notifications, please check out the following links: * 📣 [Using the CLI](https://appriseit.com/cli/) * 🛠️ [Development API](https://appriseit.com/library/) * ⚙️ [Configuration File Help](https://appriseit.com/getting-started/configuration/) * ⚡ [Create Your Own Custom Notifications](https://appriseit.com/library/extending/decorator/) * 🌎 [Apprise API/Web Interface](https://github.com/caronc/apprise-api/) * 📖 [Apprise Documentation Source](https://github.com/caronc/apprise-docs/) * 🔧 [Troubleshooting](https://appriseit.com/qa/) * 🎉 [Showcase](https://appriseit.com/contributing/showcase/) Want to help make Apprise better? * 💡 [Contribute to the Apprise Code Base](https://appriseit.com/contributing/) * ❤️ [Sponsorship and Donations](https://appriseit.com/contributing/sponsors/) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | 0.9.x | :white_check_mark: | | < 0.9.x | :x: | ## Reporting a Vulnerability If you find a vunerability, please notify me at lead2gold@gmail.com. If the vunerability is severe then please just open a ticket at https://github.com/caronc/apprise/issues ================================================ FILE: all-plugin-requirements.txt ================================================ # # Note: This file is being kept for backwards compatibility with # legacy systems that point here. All future changes should # occur in pyproject.toml. Contents of this file can be found # in [project.optional-dependencies].all-plugins # Provides fcm:// and spush:// cryptography # Provides growl:// support gntp # Provides mqtt:// support # use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814 paho-mqtt != 2.0.* # Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support PGPy # Provides smpp:// support smpplib # For xmpp:// support slixmpp >= 1.10.0 ================================================ FILE: apprise/__init__.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. __title__ = "Apprise" __description__: str = \ "Push Notifications that work with just about every platform!" __version__ = "1.9.8" __author__ = "Chris Caron" __email__ = "lead2gold@gmail.com" __license__ = "BSD 2-Clause" __copyright__ = "Copyright (c) 2026, Chris Caron " __status__ = "Production" from . import decorators, exception from .apprise import Apprise from .apprise_attachment import AppriseAttachment from .apprise_config import AppriseConfig from .asset import AppriseAsset from .attachment.base import AttachBase from .common import ( CONFIG_FORMATS, CONTENT_INCLUDE_MODES, CONTENT_LOCATIONS, NOTIFY_FORMATS, NOTIFY_IMAGE_SIZES, NOTIFY_TYPES, OVERFLOW_MODES, PERSISTENT_STORE_MODES, PERSISTENT_STORE_STATES, ConfigFormat, ContentIncludeMode, ContentLocation, NotifyFormat, NotifyImageSize, NotifyType, OverflowMode, PersistentStoreMode, ) from .config.base import ConfigBase from .locale import AppriseLocale # Inherit our logging with our additional entries added to it from .logger import LOGGER_NAME, LogCapture, logger, logging from .manager_attachment import AttachmentManager from .manager_config import ConfigurationManager from .manager_plugins import NotificationManager from .persistent_store import PersistentStore from .plugins.base import NotifyBase from .url import PrivacyMode, URLBase # Set default logging handler to avoid "No handler found" warnings. logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = [ "CONFIG_FORMATS", "CONTENT_INCLUDE_MODES", "CONTENT_LOCATIONS", "LOGGER_NAME", "NOTIFY_FORMATS", "NOTIFY_IMAGE_SIZES", "NOTIFY_TYPES", "OVERFLOW_MODES", "PERSISTENT_STORE_MODES", "PERSISTENT_STORE_STATES", # Core "Apprise", "AppriseAsset", "AppriseAttachment", "AppriseConfig", "AppriseLocale", "AttachBase", "AttachmentManager", "ConfigBase", "ConfigFormat", "ConfigurationManager", "ContentIncludeMode", "ContentLocation", "LogCapture", # Managers "NotificationManager", "NotifyBase", "NotifyFormat", "NotifyImageSize", # Reference "NotifyType", "OverflowMode", "PersistentStore", "PersistentStoreMode", "PrivacyMode", "URLBase", # Decorator "decorators", # Exceptions "exception", # Logging "logger", "logging", ] ================================================ FILE: apprise/apprise.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations import asyncio from collections.abc import Iterator import concurrent.futures as cf from itertools import chain import json import os from typing import Any, Optional, Union from . import __version__, common, plugins from .apprise_attachment import AppriseAttachment from .apprise_config import AppriseConfig from .asset import AppriseAsset from .common import ContentLocation from .config.base import ConfigBase from .conversion import convert_between from .emojis import apply_emojis from .locale import AppriseLocale from .logger import logger from .manager_plugins import NotificationManager from .plugins.base import NotifyBase from .utils.cwe312 import cwe312_url from .utils.json import AppriseJSONEncoder from .utils.logic import is_exclusive_match from .utils.parse import parse_list, parse_urls # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class Apprise: """Our Notification Manager.""" def __init__( self, servers: Optional[ Union[ str, dict, NotifyBase, AppriseConfig, ConfigBase, list[Union[str, dict, NotifyBase, AppriseConfig, ConfigBase]], ] ] = None, asset: Optional[AppriseAsset] = None, location: Optional[ContentLocation] = None, debug: bool = False, ) -> None: """Loads a set of server urls while applying the Asset() module to each if specified. If no asset is provided, then the default asset is used. Optionally specify a global ContentLocation for a more strict means of handling Attachments. """ # Initialize a server list of URLs self.servers = [] # Assigns an central asset object that will be later passed into each # notification plugin. Assets contain information such as the local # directory images can be found in. It can also identify remote # URL paths that contain the images you want to present to the end # user. If no asset is specified, then the default one is used. self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if servers: self.add(servers) # Initialize our locale object self.locale = AppriseLocale() # Set our debug flag self.debug = debug # Store our hosting location for optional strict rule handling # of Attachments. Setting this to None removes any attachment # restrictions. self.location = location @staticmethod def instantiate( url: Union[str, dict], asset: Optional[AppriseAsset] = None, tag: Optional[Union[str, list[str]]] = None, suppress_exceptions: bool = True, ) -> Optional[NotifyBase]: """Returns the instance of a instantiated plugin based on the provided Server URL. If the url fails to be parsed, then None is returned. The specified url can be either a string (the URL itself) or a dictionary containing all of the components needed to istantiate the notification service. If identifying a dictionary, at the bare minimum, one must specify the schema. An example of a url dictionary object might look like: { schema: 'mailto', host: 'google.com', user: 'myuser', password: 'mypassword', } Alternatively the string is much easier to specify: mailto://user:mypassword@google.com The dictionary works well for people who are calling details() to extract the components they need to build the URL manually. """ # Initialize our result set results = None # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() if isinstance(url, str): # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results is None: # Failed to parse the server URL; detailed logging handled # inside url_to_dict - nothing to report here. return None elif isinstance(url, dict): # We already have our result set results = url if results.get("schema") not in N_MGR: # schema is a mandatory dictionary item as it is the only way # we can index into our loaded plugins logger.error('Dictionary does not include a "schema" entry.') logger.trace( "Invalid dictionary unpacked as:{}{}".format( os.linesep, os.linesep.join( [f'{k}="{v}"' for k, v in results.items()] ), ) ) return None logger.trace( "Dictionary unpacked as:{}{}".format( os.linesep, os.linesep.join( [f'{k}="{v}"' for k, v in results.items()] ), ) ) # Otherwise we handle the invalid input specified else: logger.error( "An invalid URL type (%s) was specified for instantiation", type(url), ) return None if not N_MGR[results["schema"]].enabled: # # First Plugin Enable Check (Pre Initialization) # # Plugin has been disabled at a global level logger.error( "%s:// is disabled on this system.", results["schema"] ) return None # Build a list of tags to associate with the newly added notifications results["tag"] = set(parse_list(tag)) # Set our Asset Object results["asset"] = asset if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL logger.debug( "Loaded {} URL: {}".format( N_MGR[results["schema"]].service_name, plugin.url(privacy=asset.secure_logging), ) ) except Exception: # CWE-312 (Secure Logging) Handling loggable_url = ( url if not asset.secure_logging else cwe312_url(url) ) # the arguments are invalid or can not be used. logger.error( "Could not load {} URL: {}".format( N_MGR[results["schema"]].service_name, loggable_url ) ) return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch plugin = N_MGR[results["schema"]](**results) if not plugin.enabled: # # Second Plugin Enable Check (Post Initialization) # # Service/Plugin is disabled (on a more local level). This is a # case where the plugin was initially enabled but then after the # __init__() was called under the hood something pre-determined # that it could no longer be used. # The only downside to doing it this way is services are # initialized prior to returning the details() if 3rd party tools # are polling what is available. These services that become # disabled thereafter are shown initially that they can be used. logger.error( "%s:// has become disabled on this system.", results["schema"] ) return None return plugin def add( self, servers: Union[ str, dict, NotifyBase, AppriseConfig, ConfigBase, list[Union[str, dict, NotifyBase, AppriseConfig, ConfigBase]], ], asset: Optional[AppriseAsset] = None, tag: Optional[Union[str, list[str]]] = None, ) -> bool: """Adds one or more server URLs into our list. You can override the global asset if you wish by including it with the server(s) that you add. The tag allows you to associate 1 or more tag values to the server(s) being added. tagging a service allows you to exclusively access them when calling the notify() function. """ # Initialize our return status return_status = True if asset is None: # prepare default asset asset = self.asset if isinstance(servers, str): # build our server list servers = parse_urls(servers) if len(servers) == 0: return False elif isinstance(servers, dict): # no problem, we support kwargs, convert it to a list servers = [servers] elif isinstance(servers, (ConfigBase, NotifyBase, AppriseConfig)): # Go ahead and just add our plugin into our list self.servers.append(servers) return True elif not isinstance(servers, (tuple, set, list)): logger.error( f"An invalid notification (type={type(servers)}) was" " specified." ) return False for server in servers: if isinstance(server, (ConfigBase, NotifyBase, AppriseConfig)): # Go ahead and just add our plugin into our list self.servers.append(server) continue elif not isinstance(server, (str, dict)): logger.error( f"An invalid notification (type={type(server)}) was" " specified." ) return_status = False continue # Instantiate ourselves an object, this function throws or # returns None if it fails instance = Apprise.instantiate(server, asset=asset, tag=tag) if not isinstance(instance, NotifyBase): # No logging is required as instantiate() handles failure # and/or success reasons for us return_status = False continue # Add our initialized plugin to our server listings self.servers.append(instance) # Return our status return return_status def clear(self) -> None: """Empties our server list.""" self.servers[:] = [] def find( self, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, ) -> Iterator[NotifyBase]: """Returns a list of all servers matching against the tag specified.""" # Build our tag setup # - top level entries are treated as an 'or' # - second level (or more) entries are treated as 'and' # # examples: # tag="tagA, tagB" = tagA or tagB # tag=['tagA', 'tagB'] = tagA or tagB # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB # tag=[('tagB', 'tagC')] = tagB and tagC # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances match_always = common.MATCH_ALWAYS_TAG if match_always else None # Iterate over our loaded plugins for entry in self.servers: if isinstance(entry, (ConfigBase, AppriseConfig)): # load our servers servers = entry.servers() else: servers = [ entry, ] for server in servers: # Apply our tag matching based on our defined logic if is_exclusive_match( logic=tag, data=server.tags, match_all=common.MATCH_ALL_TAG, match_always=match_always, ): yield server return def notify( self, body: Union[str, bytes], title: Union[str, bytes] = "", notify_type: Union[str, common.NotifyType] = common.NotifyType.INFO, body_format: Optional[str] = None, tag: Any = common.MATCH_ALL_TAG, match_always: bool = True, attach: Any = None, interpret_escapes: Optional[bool] = None, ) -> Optional[bool]: """Send a notification to all the plugins previously loaded. If the body_format specified is NotifyFormat.MARKDOWN, it will be converted to HTML if the Notification type expects this. if the tag is specified (either a string or a set/list/tuple of strings), then only the notifications flagged with that tagged value are notified. By default, all added services are notified (tag=MATCH_ALL_TAG) This function returns True if all notifications were successfully sent, False if even just one of them fails, and None if no notifications were sent at all as a result of tag filtering and/or simply having empty configuration files that were read. Attach can contain a list of attachment URLs. attach can also be represented by an AttachBase() (or list of) object(s). This identifies the products you wish to notify Set interpret_escapes to True if you want to pre-escape a string such as turning a \n into an actual new line, etc. """ try: # Process arguments and build synchronous and asynchronous calls # (this step can throw internal errors). sequential_calls, parallel_calls = self._create_notify_calls( body, title, notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach, interpret_escapes=interpret_escapes, ) except TypeError: # No notifications sent, and there was an internal error. return False if not sequential_calls and not parallel_calls: # Nothing to send return None sequential_result = Apprise._notify_sequential(*sequential_calls) parallel_result = Apprise._notify_parallel_threadpool(*parallel_calls) return sequential_result and parallel_result async def async_notify( self, *args: Any, **kwargs: Any ) -> Optional[bool]: """Send a notification to all the plugins previously loaded, for asynchronous callers. The arguments are identical to those of Apprise.notify(). """ try: # Process arguments and build synchronous and asynchronous calls # (this step can throw internal errors). sequential_calls, parallel_calls = self._create_notify_calls( *args, **kwargs ) except TypeError: # No notifications sent, and there was an internal error. return False if not sequential_calls and not parallel_calls: # Nothing to send return None sequential_result = Apprise._notify_sequential(*sequential_calls) parallel_result = await Apprise._notify_parallel_asyncio( *parallel_calls ) return sequential_result and parallel_result def _create_notify_calls(self, *args, **kwargs): """Creates notifications for all the plugins loaded. Returns a list of (server, notify() kwargs) tuples for plugins with parallelism disabled and another list for plugins with parallelism enabled. """ all_calls = list(self._create_notify_gen(*args, **kwargs)) # Split into sequential and parallel notify() calls. sequential, parallel = [], [] for server, notify_kwargs in all_calls: if server.asset.async_mode: parallel.append((server, notify_kwargs)) else: sequential.append((server, notify_kwargs)) return sequential, parallel def _create_notify_gen( self, body, title="", notify_type=common.NotifyType.INFO, body_format=None, tag=common.MATCH_ALL_TAG, match_always=True, attach=None, interpret_escapes=None, ): """Internal generator function for _create_notify_calls().""" if len(self) == 0: # Nothing to notify msg = "There are no service(s) to notify" logger.error(msg) raise TypeError(msg) if not (title or body or attach): msg = "No message content specified to deliver" logger.error(msg) raise TypeError(msg) try: notify_type = ( notify_type if isinstance(notify_type, common.NotifyType) else common.NotifyType(notify_type.lower()) ) except (AttributeError, ValueError, TypeError): err = ( f"An invalid notification type ({notify_type}) was " "specified.") raise TypeError(err) from None try: if title and isinstance(title, bytes): title = title.decode(self.asset.encoding) if body and isinstance(body, bytes): body = body.decode(self.asset.encoding) except UnicodeDecodeError: msg = ( "The content passed into Apprise was not of encoding " f"type: {self.asset.encoding}" ) logger.error(msg) raise TypeError(msg) from None # Tracks conversions conversion_body_map = {} conversion_title_map = {} # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): attach = AppriseAttachment( attach, asset=self.asset, location=self.location ) # Allow Asset default value body_format = ( self.asset.body_format if body_format is None else body_format ) # Allow Asset default value interpret_escapes = ( self.asset.interpret_escapes if interpret_escapes is None else interpret_escapes ) # Iterate over our loaded plugins for server in self.find(tag, match_always=match_always): # If our code reaches here, we either did not define a tag (it # was set to None), or we did define a tag and the logic above # determined we need to notify the service it's associated with # First we need to generate a key we will use to determine if we # need to build our data out. Entries without are merged with # the body at this stage. key = ( server.notify_format if server.title_maxlen > 0 else f"_{server.notify_format}" ) if server.interpret_emojis: # alter our key slightly to handle emojis since their value is # pulled out of the notification key += "-emojis" if key not in conversion_title_map: # Prepare our title conversion_title_map[key] = title if title else "" # Conversion of title only occurs for services where the title # is blended with the body (title_maxlen <= 0) if conversion_title_map[key] and server.title_maxlen <= 0: conversion_title_map[key] = convert_between( body_format, server.notify_format, content=conversion_title_map[key], ) # Our body is always converted no matter what conversion_body_map[key] = convert_between( body_format, server.notify_format, content=body ) if interpret_escapes: # # Escape our content # try: # Added overhead required due to Python 3 Encoding Bug # identified here: https://bugs.python.org/issue21331 conversion_body_map[key] = ( conversion_body_map[key] .encode("ascii", "backslashreplace") .decode("unicode-escape") ) conversion_title_map[key] = ( conversion_title_map[key] .encode("ascii", "backslashreplace") .decode("unicode-escape") ) except AttributeError: # Must be of string type msg = "Failed to escape message body" logger.error(msg) raise TypeError(msg) from None if server.interpret_emojis: # # Convert our :emoji: definitions # conversion_body_map[key] = apply_emojis( conversion_body_map[key] ) conversion_title_map[key] = apply_emojis( conversion_title_map[key] ) kwargs = { "body": conversion_body_map[key], "title": conversion_title_map[key], "notify_type": notify_type, "attach": attach, "body_format": body_format, } yield (server, kwargs) @staticmethod def _notify_sequential(*servers_kwargs): """Process a list of notify() calls sequentially and synchronously.""" success = True for server, kwargs in servers_kwargs: try: # Send notification result = server.notify(**kwargs) success = success and result except TypeError: # These are our internally thrown notifications. success = False except Exception: # A catch all so we don't have to abort early # just because one of our plugins has a bug in it. logger.exception("Unhandled Notification Exception") success = False return success @staticmethod def _notify_parallel_threadpool(*servers_kwargs): """Process a list of notify() calls in parallel and synchronously.""" n_calls = len(servers_kwargs) # 0-length case if n_calls == 0: return True # There's no need to use a thread pool for just a single notification if n_calls == 1: return Apprise._notify_sequential(servers_kwargs[0]) # Create log entry logger.info( "Notifying %d service(s) with threads.", len(servers_kwargs) ) with cf.ThreadPoolExecutor() as executor: success = True futures = [ executor.submit(server.notify, **kwargs) for (server, kwargs) in servers_kwargs ] for future in cf.as_completed(futures): try: result = future.result() success = success and result except TypeError: # These are our internally thrown notifications. success = False except Exception: # A catch all so we don't have to abort early # just because one of our plugins has a bug in it. logger.exception("Unhandled Notification Exception") success = False return success @staticmethod async def _notify_parallel_asyncio(*servers_kwargs): """Process a list of async_notify() calls in parallel and asynchronously.""" n_calls = len(servers_kwargs) # 0-length case if n_calls == 0: return True # (Unlike with the thread pool, we don't optimize for the single- # notification case because asyncio can do useful work while waiting # for that thread to complete) # Create log entry logger.info( "Notifying %d service(s) asynchronously.", len(servers_kwargs) ) async def do_call(server, kwargs): return await server.async_notify(**kwargs) cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs) results = await asyncio.gather(*cors, return_exceptions=True) if any( isinstance(status, Exception) and not isinstance(status, TypeError) for status in results ): # A catch all so we don't have to abort early just because # one of our plugins has a bug in it. logger.exception("Unhandled Notification Exception") return False if any(isinstance(status, TypeError) for status in results): # These are our internally thrown notifications. return False return all(results) def json( self, lang: Optional[str] = None, show_requirements: bool = False, show_disabled: bool = False, indent: Optional[int] = None, path: Optional[str] = None, ) -> Union[str, bool]: """Returns a json response associated with the Apprise object.""" details = self.details( lang=lang, show_requirements=show_requirements, show_disabled=show_disabled, ) if not path: return json.dumps( details, separators=(",", ":"), indent=indent, cls=AppriseJSONEncoder, ) with open(path, "w") as fp: try: json.dump( details, fp, separators=(",", ":"), indent=indent, cls=AppriseJSONEncoder, ensure_ascii=False, ) except (OSError, EOFError) as e: logger.error( "Apprise details dumpfile inaccessible: %s", path ) logger.debug("Apprise details dump Exception: %s", e) # Early Exit return False finally: # Reduce memory del details return True def details( self, lang: Optional[str] = None, show_requirements: bool = False, show_disabled: bool = False, ) -> dict[str, Any]: """Returns the details associated with the Apprise object.""" # general object returned response = { # Defines the current version of Apprise "version": __version__, # Lists all of the currently supported Notifications "schemas": [], # Includes the configured asset details "asset": self.asset.details(), } for plugin in N_MGR.plugins(): # Iterate over our hashed plugins and dynamically build details on # their status: content = { "service_name": getattr(plugin, "service_name", None), "service_url": getattr(plugin, "service_url", None), "setup_url": getattr(plugin, "setup_url", None), # Placeholder - populated below "details": None, # Let upstream service know of the plugins that support # attachments "attachment_support": getattr( plugin, "attachment_support", False ), # Differentiat between what is a custom loaded plugin and # which is native. "category": getattr(plugin, "category", None), } # Standard protocol(s) should be None or a tuple enabled = getattr(plugin, "enabled", True) if not show_disabled and not enabled: # Do not show inactive plugins continue elif show_disabled: # Add current state to response content["enabled"] = enabled # Standard protocol(s) should be None or a tuple protocols = getattr(plugin, "protocol", None) if isinstance(protocols, str): protocols = (protocols,) # Secure protocol(s) should be None or a tuple secure_protocols = getattr(plugin, "secure_protocol", None) if isinstance(secure_protocols, str): secure_protocols = (secure_protocols,) # Add our protocol details to our content content.update({ "protocols": protocols, "secure_protocols": secure_protocols, }) if not lang: # Simply return our results content["details"] = plugins.details(plugin) if show_requirements: content["requirements"] = plugins.requirements(plugin) else: # Emulate the specified language when returning our results with self.locale.lang_at(lang): content["details"] = plugins.details(plugin) if show_requirements: content["requirements"] = plugins.requirements(plugin) # Build our response object response["schemas"].append(content) return response def urls(self, privacy: bool = False) -> list[str]: """Returns all of the loaded URLs defined in this apprise object.""" urls = [] for s in self.servers: if isinstance(s, (ConfigBase, AppriseConfig)): for s_ in s.servers(): urls.append(s_.url(privacy=privacy)) else: urls.append(s.url(privacy=privacy)) return urls def pop(self, index: int) -> NotifyBase: """Removes an indexed Notification Service from the stack and returns it. The thing is we can never pop AppriseConfig() entries, only what was loaded within them. So pop needs to carefully iterate over our list and only track actual entries. """ # Tracking variables prev_offset = -1 offset = prev_offset for idx, s in enumerate(self.servers): if isinstance(s, (ConfigBase, AppriseConfig)): servers = s.servers() if len(servers) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(servers) if offset >= index: # we can pop an element from our config stack fn = ( s.pop if isinstance(s, ConfigBase) else s.server_pop ) return fn( index if prev_offset == -1 else (index - prev_offset - 1) ) else: offset = prev_offset + 1 if offset == index: return self.servers.pop(idx) # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def __getitem__(self, index: int) -> NotifyBase: """Returns the indexed server entry of a loaded notification server.""" # Tracking variables prev_offset = -1 offset = prev_offset for idx, s in enumerate(self.servers): if isinstance(s, (ConfigBase, AppriseConfig)): # Get our list of servers associate with our config object servers = s.servers() if len(servers) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(servers) if offset >= index: return servers[( index if prev_offset == -1 else (index - prev_offset - 1) )] else: offset = prev_offset + 1 if offset == index: return self.servers[idx] # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def __getstate__(self) -> dict[str, object]: """Pickle Support dumps()""" attributes = { "asset": self.asset, # Prepare our URL list as we need to extract the associated tags # and asset details associated with it "urls": [ { "url": server.url(privacy=False), "tag": server.tags if server.tags else None, "asset": server.asset, } for server in self.servers ], "locale": self.locale, "debug": self.debug, "location": self.location.value if self.location else None, } return attributes def __setstate__(self, state: dict[str, object]) -> None: """Pickle Support loads()""" self.servers = [] self.asset = state["asset"] self.locale = state["locale"] location = state.get("location") self.location = ( location if isinstance(location, ContentLocation) else ContentLocation(location) if location is not None else None ) for entry in state["urls"]: self.add(entry["url"], asset=entry["asset"], tag=entry["tag"]) def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return len(self) > 0 def __iter__(self) -> Iterator[NotifyBase]: """Returns an iterator to each of our servers loaded. This includes those found inside configuration. """ return chain(*[ ( [s] if not isinstance(s, (ConfigBase, AppriseConfig)) else iter(s.servers()) ) for s in self.servers ]) def __len__(self) -> int: """Returns the number of servers loaded; this includes those found within loaded configuration. This funtion nnever actually counts the Config entry themselves (if they exist), only what they contain. """ return sum(( 1 if not isinstance(s, (ConfigBase, AppriseConfig)) else len(s.servers()) ) for s in self.servers) ================================================ FILE: apprise/apprise_attachment.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from collections.abc import Iterator from typing import Any, Optional, Union from .asset import AppriseAsset from .attachment.base import AttachBase from .common import ContentLocation from .logger import logger from .manager_attachment import AttachmentManager from .url import URLBase from .utils.parse import GET_SCHEMA_RE # Grant access to our Notification Manager Singleton A_MGR = AttachmentManager() class AppriseAttachment: """Our Apprise Attachment File Manager.""" def __init__( self, paths: Optional[Union[str, list[ Union[str, AttachBase, "AppriseAttachment"]]]] = None, asset: Optional[AppriseAsset] = None, cache: Union[bool, int] = True, location: Optional[Union[str, ContentLocation]] = None, **kwargs: Any, ) -> None: """Loads all of the paths/urls specified (if any). The path can either be a single string identifying one explicit location, otherwise you can pass in a series of locations to scan via a list. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass AttachBase() Optionally set your current ContentLocation in the location argument. This is used to further handle attachments. The rules are as follows: - INACCESSIBLE: You simply have disabled use of the object; no attachments will be retrieved/handled. - HOSTED: You are hosting an attachment service for others. In these circumstances all attachments that are LOCAL based (such as file://) will not be allowed. - LOCAL: The least restrictive mode as local files can be referenced in addition to hosted. In all but HOSTED and LOCAL modes, INACCESSIBLE attachment types will continue to be inaccessible. However if you set this field (location) to None (it's default value) the attachment location category will not be tested in any way (all attachment types will be allowed). The location field is also a global option that can be set when initializing the Apprise object. """ # Initialize our attachment listings self.attachments = [] # Set our cache flag self.cache = cache # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if location: try: self.location = ( location if isinstance(location, ContentLocation) else ContentLocation(location.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid Attachment location ({location}) was " "specified.", ) logger.warning(err) raise TypeError(err) from None else: # do not set location if no initialization was made for it self.location = None # Now parse any paths specified if paths is not None and not self.add(paths): raise TypeError("One or more attachments could not be added.") def add( self, attachments: Union[ str, AttachBase, "AppriseAttachment", list[Union[str, AttachBase, "AppriseAttachment"]], ], asset: Optional[AppriseAsset] = None, cache: Optional[Union[bool, int]] = None, ) -> bool: """Adds one or more attachments into our list. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass AttachBase() """ # Initialize our return status return_status = True # Initialize our default cache value cache = cache if cache is not None else self.cache if asset is None: # prepare default asset asset = self.asset if isinstance(attachments, (AttachBase, str)): # store our instance attachments = (attachments,) elif not isinstance(attachments, (tuple, set, list)): logger.error( f"An invalid attachment url (type={type(attachments)}) was " "specified." ) return False # Iterate over our attachments for attachment in attachments: if self.location == ContentLocation.INACCESSIBLE: logger.warning( f"Attachments are disabled; ignoring {attachment}" ) return_status = False continue if isinstance(attachment, str): logger.debug(f"Loading attachment: {attachment}") # Instantiate ourselves an object, this function throws or # returns None if it fails instance = AppriseAttachment.instantiate( attachment, asset=asset, cache=cache ) if not isinstance(instance, AttachBase): return_status = False continue elif isinstance(attachment, AppriseAttachment): # We were provided a list of Apprise Attachments # append our content together instance = attachment.attachments elif not isinstance(attachment, AttachBase): logger.warning( f"An invalid attachment (type={type(attachment)}) was" " specified." ) return_status = False continue else: # our entry is of type AttachBase, so just go ahead and point # our instance to it for some post processing below instance = attachment # Apply some simple logic if our location flag is set if self.location and ( ( self.location == ContentLocation.HOSTED and instance.location != ContentLocation.HOSTED ) or instance.location == ContentLocation.INACCESSIBLE ): logger.warning( "Attachment was disallowed due to accessibility" f" restrictions ({self.location}->{instance.location}):" f" {instance.url(privacy=True)}" ) return_status = False continue # Add our initialized plugin to our server listings if isinstance(instance, list): self.attachments.extend(instance) else: self.attachments.append(instance) # Return our status return return_status @staticmethod def instantiate( url: str, asset: Optional[AppriseAsset] = None, cache: Optional[Union[bool, int]] = None, suppress_exceptions: bool = True, ) -> Optional[AttachBase]: """Returns the instance of a instantiated attachment plugin based on the provided Attachment URL. If the url fails to be parsed, then None is returned. A specified cache value will over-ride anything set """ # Attempt to acquire the schema at the very least to allow our # attachment based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in A_MGR: logger.warning(f"Unsupported schema {schema}.") return None # Parse our url details of the server object as dictionary containing # all of the information parsed from our URL results = A_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL logger.warning(f"Unparseable URL {url}.") return None # Prepare our Asset Object results["asset"] = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if cache is not None: # Force an over-ride of the cache value to what we have specified results["cache"] = cache if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information attach_plugin = A_MGR[results["schema"]](**results) except Exception: # the arguments are invalid or can not be used. logger.warning(f"Could not load URL: {url}") return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch attach_plugin = A_MGR[results["schema"]](**results) return attach_plugin def sync( self, abort_on_error: bool = True, abort_if_empty: bool = True, ) -> bool: """Itereates over all of the attachments and retrieves them.""" return ( False if abort_if_empty and not self.attachments else ( next((False for a in self.attachments if not a), True) if abort_on_error else next((True for a in self.attachments), True) ) ) def clear(self) -> None: """Empties our attachment list.""" self.attachments[:] = [] def size(self) -> int: """Returns the total size of accumulated attachments.""" return sum(len(a) for a in self.attachments if len(a) > 0) def pop(self, index: int = -1) -> AttachBase: """Removes an indexed Apprise Attachment from the stack and returns it. by default the last element is poped from the list """ # Remove our entry return self.attachments.pop(index) def __getitem__(self, index: int) -> AttachBase: """Returns the indexed entry of a loaded apprise attachments.""" return self.attachments[index] def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return bool(self.attachments) def __iter__(self) -> Iterator[AttachBase]: """Returns an iterator to our attachment list.""" return iter(self.attachments) def __len__(self) -> int: """Returns the number of attachment entries loaded.""" return len(self.attachments) ================================================ FILE: apprise/apprise_config.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from typing import TYPE_CHECKING, Any from . import common from .asset import AppriseAsset from .config.base import ConfigBase from .logger import logger from .manager_config import ConfigurationManager from .url import URLBase from .utils.logic import is_exclusive_match from .utils.parse import GET_SCHEMA_RE, parse_list if TYPE_CHECKING: from .plugins.base import NotifyBase # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() class AppriseConfig: """Our Apprise Configuration File Manager. - Supports a list of URLs defined one after another (text format) - Supports a destinct YAML configuration format """ def __init__( self, paths: str | list[str] | None = None, asset: AppriseAsset | None = None, cache: bool | int = True, recursion: int = 0, insecure_includes: bool = False, **kwargs: Any, ) -> None: """Loads all of the paths specified (if any). The path can either be a single string identifying one explicit location, otherwise you can pass in a series of locations to scan via a list. If no path is specified then a default list is used. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. Setting this to False does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled and you're set up to make remote calls. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass ConfigBase() recursion defines how deep we recursively handle entries that use the `import` keyword. This keyword requires us to fetch more configuration from another source and add it to our existing compilation. If the file we remotely retrieve also has an `import` reference, we will only advance through it if recursion is set to 2 deep. If set to zero it is off. There is no limit to how high you set this value. It would be recommended to keep it low if you do intend to use it. insecure includes by default are disabled. When set to True, all Apprise Config files marked to be in STRICT mode are treated as being in ALWAYS mode. Take a file:// based configuration for example, only a file:// based configuration can import another file:// based one. because it is set to STRICT mode. If an http:// based configuration file attempted to import a file:// one it woul fail. However this import would be possible if insecure_includes is set to True. There are cases where a self hosting apprise developer may wish to load configuration from memory (in a string format) that contains import entries (even file:// based ones). In these circumstances if you want these includes to be honored, this value must be set to True. """ # Initialize a server list of URLs self.configs = [] # Prepare our Asset Object self.asset = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) # Set our cache flag self.cache = cache # Initialize our recursion value self.recursion = recursion # Initialize our insecure_includes flag self.insecure_includes = insecure_includes if paths is not None: # Store our path(s) self.add(paths) return def add( self, configs: str | ConfigBase | list[str | ConfigBase], asset: AppriseAsset | None = None, tag: str | list[str] | None = None, cache: bool | int = True, recursion: int | None = None, insecure_includes: bool | None = None, ) -> bool: """Adds one or more config URLs into our list. You can override the global asset if you wish by including it with the config(s) that you add. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. Setting this to False does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled and you're set up to make remote calls. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. It's also worth nothing that the cache value is only set to elements that are not already of subclass ConfigBase() Optionally override the default recursion value. Optionally override the insecure_includes flag. if insecure_includes is set to True then all plugins that are set to a STRICT mode will be a treated as ALWAYS. """ # Initialize our return status return_status = True # Initialize our default cache value cache = cache if cache is not None else self.cache # Initialize our default recursion value recursion = recursion if recursion is not None else self.recursion # Initialize our default insecure_includes value insecure_includes = ( insecure_includes if insecure_includes is not None else self.insecure_includes ) if asset is None: # prepare default asset asset = self.asset if isinstance(configs, ConfigBase): # Go ahead and just add our configuration into our list self.configs.append(configs) return True elif isinstance(configs, str): # Save our path configs = (configs,) elif not isinstance(configs, (tuple, set, list)): logger.error( f"An invalid configuration path (type={type(configs)}) was " "specified." ) return False # Iterate over our configuration for config in configs: if isinstance(config, ConfigBase): # Go ahead and just add our configuration into our list self.configs.append(config) continue elif not isinstance(config, str): logger.warning( f"An invalid configuration (type={type(config)}) was" " specified." ) return_status = False continue logger.debug(f"Loading configuration: {config}") # Instantiate ourselves an object, this function throws or # returns None if it fails instance = AppriseConfig.instantiate( config, asset=asset, tag=tag, cache=cache, recursion=recursion, insecure_includes=insecure_includes, ) if not isinstance(instance, ConfigBase): return_status = False continue # Add our initialized plugin to our server listings self.configs.append(instance) # Return our status return return_status def add_config( self, content: str, asset: AppriseAsset | None = None, tag: str | list[str] | None = None, format: str | None = None, recursion: int | None = None, insecure_includes: bool | None = None, ) -> bool: """Adds one configuration file in it's raw format. Content gets loaded as a memory based object and only exists for the life of this AppriseConfig object it was loaded into. If you know the format ('yaml' or 'text') you can specify it for slightly less overhead during this call. Otherwise the configuration is auto-detected. Optionally override the default recursion value. Optionally override the insecure_includes flag. if insecure_includes is set to True then all plugins that are set to a STRICT mode will be a treated as ALWAYS. """ # Initialize our default recursion value recursion = recursion if recursion is not None else self.recursion # Initialize our default insecure_includes value insecure_includes = ( insecure_includes if insecure_includes is not None else self.insecure_includes ) if asset is None: # prepare default asset asset = self.asset if not isinstance(content, str): logger.warning( f"An invalid configuration (type={type(content)}) was" " specified." ) return False logger.debug(f"Loading raw configuration: {content}") # Create ourselves a ConfigMemory Object to store our configuration instance = C_MGR["memory"]( content=content, format=format, asset=asset, tag=tag, recursion=recursion, insecure_includes=insecure_includes, ) if not (instance.config_format and instance.config_format.value in common.CONFIG_FORMATS): logger.warning( "The format of the configuration could not be detected." ) return False # Add our initialized plugin to our server listings self.configs.append(instance) # Return our status return True def servers( self, tag: str | list[str] = common.MATCH_ALL_TAG, match_always: bool = True, *args: Any, **kwargs: Any, ) -> list[NotifyBase]: """Returns all of our servers dynamically build based on parsed configuration. If a tag is specified, it applies to the configuration sources themselves and not the notification services inside them. This is for filtering the configuration files polled for results. If the anytag is set, then any notification that is found set with that tag are included in the response. """ # A match_always flag allows us to pick up on our 'any' keyword # and notify these services under all circumstances match_always = common.MATCH_ALWAYS_TAG if match_always else None # Build our tag setup # - top level entries are treated as an 'or' # - second level (or more) entries are treated as 'and' # # examples: # tag="tagA, tagB" = tagA or tagB # tag=['tagA', 'tagB'] = tagA or tagB # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB # tag=[('tagB', 'tagC')] = tagB and tagC response = [] for entry in self.configs: # Apply our tag matching based on our defined logic if is_exclusive_match( logic=tag, data=entry.tags, match_all=common.MATCH_ALL_TAG, match_always=match_always, ): # Build ourselves a list of services dynamically and return the # as a list response.extend(entry.servers()) return response @staticmethod def instantiate( url: str, asset: AppriseAsset | None = None, tag: str | list[str] | None = None, cache: bool | int | None = None, recursion: int = 0, insecure_includes: bool = False, suppress_exceptions: bool = True, ) -> ConfigBase | None: """Returns the instance of a instantiated configuration plugin based on the provided Config URL. If the url fails to be parsed, then None is returned. """ # Attempt to acquire the schema at the very least to allow our # configuration based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in C_MGR: logger.warning(f"Unsupported schema {schema}.") return None # Parse our url details of the server object as dictionary containing # all of the information parsed from our URL results = C_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL logger.warning(f"Unparseable URL {url}.") return None # Build a list of tags to associate with the newly added notifications results["tag"] = set(parse_list(tag)) # Prepare our Asset Object results["asset"] = ( asset if isinstance(asset, AppriseAsset) else AppriseAsset() ) if cache is not None: # Force an over-ride of the cache value to what we have specified results["cache"] = cache # Recursion can never be parsed from the URL results["recursion"] = recursion # Insecure includes flag can never be parsed from the URL results["insecure_includes"] = insecure_includes if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed # URL information cfg_plugin = C_MGR[results["schema"]](**results) except Exception: # the arguments are invalid or can not be used. logger.warning(f"Could not load URL: {url}") return None else: # Attempt to create an instance of our plugin using the parsed # URL information but don't wrap it in a try catch cfg_plugin = C_MGR[results["schema"]](**results) return cfg_plugin def clear(self) -> None: """Empties our configuration list.""" self.configs[:] = [] def server_pop(self, index: int) -> NotifyBase: """Removes an indexed Apprise Notification from the servers.""" # Tracking variables prev_offset = -1 offset = prev_offset for entry in self.configs: servers = entry.servers(cache=True) if len(servers) > 0: # Acquire a new maximum offset to work with offset = prev_offset + len(servers) if offset >= index: # we can pop an notification from our config stack return entry.pop( index if prev_offset == -1 else (index - prev_offset - 1) ) # Update our old offset prev_offset = offset # If we reach here, then we indexed out of range raise IndexError("list index out of range") def pop(self, index: int = -1) -> ConfigBase: """Removes an indexed Apprise Configuration from the stack and returns it. By default, the last element is removed from the list """ # Remove our entry return self.configs.pop(index) def __getitem__(self, index: int) -> ConfigBase: """Returns the indexed config entry of a loaded apprise configuration.""" return self.configs[index] def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if at least one service has been loaded. """ return bool(self.configs) def __iter__(self): # type: () -> Iterator[ConfigBase] """Returns an iterator to our config list.""" return iter(self.configs) def __len__(self) -> int: """Returns the number of config entries loaded.""" return len(self.configs) ================================================ FILE: apprise/asset.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime, tzinfo from os.path import abspath, dirname, isfile, join import re from typing import Any, Optional, Union from uuid import uuid4 from .common import ( NotifyFormat, NotifyImageSize, NotifyType, PersistentStoreMode, ) from .manager_plugins import NotificationManager from .utils.time import zoneinfo # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class AppriseAsset: """Provides a supplimentary class that can be used to provide extra information and details that can be used by Apprise such as providing an alternate location to where images/icons can be found and the URL masks. Any variable that starts with an underscore (_) can only be initialized by this class manually and will/can not be parsed from a configuration file. """ # Application Identifier app_id = "Apprise" # Application Description app_desc = "Apprise Notifications" # Provider URL app_url = "https://github.com/caronc/apprise" # A Simple Mapping of Colors; For every NOTIFY_TYPE identified, # there should be a mapping to it's color here: html_notify_map = { NotifyType.INFO: "#3AA3E3", NotifyType.SUCCESS: "#3AA337", NotifyType.FAILURE: "#A32037", NotifyType.WARNING: "#CACF29", } # The default color to return if a mapping isn't found in our table above default_html_color = "#888888" # Ascii Notification ascii_notify_map = { NotifyType.INFO: "[i]", NotifyType.SUCCESS: "[+]", NotifyType.FAILURE: "[!]", NotifyType.WARNING: "[~]", } # The default ascii to return if a mapping isn't found in our table above default_ascii_chars = "[?]" # The default image extension to use default_extension = ".png" # The default image size if one isn't specified default_image_size = NotifyImageSize.XY_256 # The default theme theme = "default" # Image URL Mask image_url_mask = ( "https://github.com/caronc/apprise/raw/master/apprise/assets/" "themes/{THEME}/apprise-{TYPE}-{XY}{EXTENSION}" ) # Application Logo image_url_logo = ( "https://github.com/caronc/apprise/raw/master/apprise/assets/" "themes/{THEME}/apprise-logo.png" ) # Image Path Mask image_path_mask = abspath( join( dirname(__file__), "assets", "themes", "{THEME}", "apprise-{TYPE}-{XY}{EXTENSION}", ) ) # This value can also be set on calls to Apprise.notify(). This allows # you to let Apprise upfront the type of data being passed in. This # must be of type NotifyFormat. Possible values could be: # - NotifyFormat.TEXT # - NotifyFormat.MARKDOWN # - NotifyFormat.HTML # - None # # If no format is specified (hence None), then no special pre-formatting # actions will take place during a notification. This has been and always # will be the default. body_format = None # Always attempt to send notifications asynchronous (as the same time # if possible) # This is a Python 3 supported option only. If set to False, then # notifications are sent sequentially (one after another) async_mode = True # Support :smile:, and other alike keywords swapping them for their # unicode value. A value of None leaves the interpretation up to the # end user to control (allowing them to specify emojis=yes on the # URL) interpret_emojis = None # Whether or not to interpret escapes found within the input text prior # to passing it upstream. Such as converting \t to an actual tab and \n # to a new line. interpret_escapes = False # Defines the encoding of the content passed into Apprise encoding = "utf-8" # Automatically generate our Pretty Good Privacy (PGP) keys if one isn't # present and our environment configuration allows for it. # For example, a case where the environment wouldn't allow for it would be # if Persistent Storage was set to `memory` pgp_autogen = True # Automatically generate our Privacy Enhanced Mail (PEM) keys if one isn't # present and our environment configuration allows for it. # For example, a case where the environment wouldn't allow for it would be # if Persistent Storage was set to `memory` pem_autogen = True # For more detail see CWE-312 @ # https://cwe.mitre.org/data/definitions/312.html # # By enabling this, the logging output has additional overhead applied to # it preventing secure password and secret information from being # displayed in the logging. Since there is overhead involved in performing # this cleanup; system owners who run in a very isolated environment may # choose to disable this for a slight performance bump. It is recommended # that you leave this option as is otherwise. secure_logging = True # Optionally specify one or more path to attempt to scan for Python modules # By default, no paths are scanned. __plugin_paths = [] # Optionally set the location of the persistent storage # By default there is no path and thus persistent storage is not used __storage_path = None # Optionally define the default salt to apply to all persistent storage # namespace generation (unless over-ridden) __storage_salt = b"" # Optionally define the namespace length of the directories created by # the storage. If this is set to zero, then the length is pre-determined # by the generator (sha1, md5, sha256, etc) __storage_idlen = 8 # Set storage to auto __storage_mode = PersistentStoreMode.AUTO # All internal/system flags are prefixed with an underscore (_) # These can only be initialized using Python libraries and are not picked # up from (yaml) configuration files (if set) # An internal counter that is used by AppriseAPI # (https://github.com/caronc/apprise-api). The idea is to allow one # instance of AppriseAPI to call another, but to track how many times # this occurs. It's intent is to prevent a loop where an AppriseAPI # Server calls itself (or loops indefinitely) _recursion = 0 # A unique identifer we can use to associate our calling source _uid = str(uuid4()) # Default timezone to use (pass in timezone value) # A list of timezones can be found here: # https://en.wikipedia.org/wiki/List_of_tz_database_time_zones # You can specify things such as 'America/Montreal' # If no timezone is specified, then the one detected on the system # is uzed _tzinfo = None def __init__( self, plugin_paths: Optional[list[str]] = None, storage_path: Optional[str] = None, storage_mode: Optional[Union[str, PersistentStoreMode]] = None, storage_salt: Optional[Union[str, bytes]] = None, storage_idlen: Optional[int] = None, timezone: Optional[Union[str, tzinfo]] = None, **kwargs: Any ) -> None: """Asset Initialization.""" # Assign default arguments if specified for key, value in kwargs.items(): if not hasattr(AppriseAsset, key): raise AttributeError( f"AppriseAsset init(): An invalid key {key} was specified." ) setattr(self, key, value) if plugin_paths: # Load any decorated modules if defined self.__plugin_paths = plugin_paths N_MGR.module_detection(plugin_paths) if storage_path: # Define our persistent storage path self.__storage_path = storage_path if storage_mode: # Define how our persistent storage behaves try: self.__storage_mode = ( storage_mode if isinstance(storage_mode, NotifyFormat) else PersistentStoreMode(storage_mode.lower()) ) except (AttributeError, ValueError, TypeError): err = ( f"An invalid persistent store mode ({storage_mode}) was " "specified.") raise AttributeError(err) from None if isinstance(storage_idlen, int): # Define the number of characters utilized from our namespace lengh if storage_idlen < 0: # Unsupported type raise ValueError( "AppriseAsset storage_idlen(): Value must " "be an integer and > 0" ) # Store value self.__storage_idlen = storage_idlen if isinstance(timezone, tzinfo): self._tzinfo = timezone elif timezone is not None: self._tzinfo = zoneinfo(timezone) if not self._tzinfo: raise AttributeError( "AppriseAsset timezone provided is invalid") from None else: # Default our timezone to what is detected on the system self._tzinfo = datetime.now().astimezone().tzinfo if storage_salt is not None: # Define the number of characters utilized from our namespace lengh if isinstance(storage_salt, bytes): self.__storage_salt = storage_salt elif isinstance(storage_salt, str): try: self.__storage_salt = storage_salt.encode(self.encoding) except UnicodeEncodeError: # Bad data; don't pass it along raise ValueError( "AppriseAsset namespace_salt(): " "Value provided could not be encoded" ) from None else: # Unsupported raise ValueError( "AppriseAsset namespace_salt(): Value provided must be " "string or bytes object" ) def color( self, notify_type: NotifyType, color_type: Optional[type] = None, ) -> Union[str, int, tuple[int, int, int]]: """Returns an HTML mapped color based on passed in notify type. if color_type is: None then a standard hex string is returned as a string format ('#000000'). int then the integer representation is returned tuple then the the red, green, blue is returned in a tuple """ # Attempt to get the type, otherwise return a default grey # if we couldn't look up the entry color = self.html_notify_map.get( notify_type, self.default_html_color) if color_type is None: # This is the default return type return color elif color_type is int: # Convert the color to integer return AppriseAsset.hex_to_int(color) # The only other type is tuple elif color_type is tuple: return AppriseAsset.hex_to_rgb(color) # Unsupported type raise ValueError( "AppriseAsset html_color(): An invalid color_type was specified." ) def ascii(self, notify_type: NotifyType) -> str: """Returns an ascii representation based on passed in notify type.""" # look our response up return self.ascii_notify_map.get( notify_type, self.default_ascii_chars) def image_url( self, notify_type: NotifyType, image_size: Optional[NotifyImageSize] = None, logo: bool = False, extension: Optional[str] = None, ) -> Optional[str]: """Apply our mask to our image URL. if logo is set to True, then the logo_url is used instead """ url_mask = self.image_url_logo if logo else self.image_url_mask if not url_mask: # No image to return return None if extension is None: extension = self.default_extension if image_size is None: image_size = self.default_image_size re_map = { "{THEME}": self.theme if self.theme else "", "{TYPE}": notify_type.value, "{XY}": image_size.value, "{EXTENSION}": extension, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) return re_table.sub(lambda x: re_map[x.group()], url_mask) def image_path( self, notify_type: NotifyType, image_size: NotifyImageSize, must_exist: bool = True, extension: Optional[str] = None, ) -> Optional[str]: """Apply our mask to our image file path.""" if not self.image_path_mask: # No image to return return None if extension is None: extension = self.default_extension re_map = { "{THEME}": self.theme if self.theme else "", "{TYPE}": notify_type.value, "{XY}": image_size.value, "{EXTENSION}": extension, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) # Acquire our path path = re_table.sub(lambda x: re_map[x.group()], self.image_path_mask) if must_exist and not isfile(path): return None # Return what we parsed return path def image_raw( self, notify_type: NotifyType, image_size: NotifyImageSize, extension: Optional[str] = None, ) -> Optional[bytes]: """Returns the raw image if it can (otherwise the function returns None)""" path = self.image_path( notify_type=notify_type, image_size=image_size, extension=extension, ) if path: try: with open(path, "rb") as fd: return fd.read() except OSError: # We can't access the file return None return None def details(self) -> dict[str, str]: """Returns the details associated with the AppriseAsset object.""" return { "app_id": self.app_id, "app_desc": self.app_desc, "default_extension": self.default_extension, "theme": self.theme, "image_path_mask": self.image_path_mask, "image_url_mask": self.image_url_mask, "image_url_logo": self.image_url_logo, } @staticmethod def hex_to_rgb(value: str) -> tuple[int, int, int]: """Takes a hex string (such as #00ff00) and returns a tuple in the form of (red, green, blue) eg: #00ff00 becomes : (0, 65535, 0) """ value = value.lstrip("#") lv = len(value) return tuple( int(value[i : i + lv // 3], 16) for i in range(0, lv, lv // 3) ) @staticmethod def hex_to_int(value: str) -> int: """Takes a hex string (such as #00ff00) and returns its integer equivalent. eg: #00000f becomes : 15 """ return int(value.lstrip("#"), 16) @property def plugin_paths(self) -> list[str]: """Return the plugin paths defined.""" return self.__plugin_paths @property def storage_path(self) -> Optional[str]: """Return the persistent storage path defined.""" return self.__storage_path @property def storage_mode(self) -> PersistentStoreMode: """Return the persistent storage mode defined.""" return self.__storage_mode @property def storage_salt(self) -> bytes: """Return the provided namespace salt; this is always of type bytes.""" return self.__storage_salt @property def storage_idlen(self) -> int: """Return the persistent storage id length.""" return self.__storage_idlen @property def tzinfo(self) -> tzinfo: """Return the timezone object""" return self._tzinfo ================================================ FILE: apprise/assets/NotifyXML-1.0.xsd ================================================ ================================================ FILE: apprise/assets/NotifyXML-1.1.xsd ================================================ ================================================ FILE: apprise/attachment/__init__.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Used for testing from ..manager_attachment import AttachmentManager from .base import AttachBase # Initalize our Attachment Manager Singleton A_MGR = AttachmentManager() __all__ = [ # Reference "AttachBase", "AttachmentManager", ] ================================================ FILE: apprise/attachment/base.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import contextlib import mimetypes import os import time from .. import exception from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..url import URLBase from ..utils.parse import parse_bool class AttachBase(URLBase): """This is the base class for all supported attachment types.""" # For attachment type detection; this amount of data is read into memory # 128KB (131072B) max_detect_buffer_size = 131072 # Unknown mimetype unknown_mimetype = "application/octet-stream" # Our filename when we can't otherwise determine one unknown_filename = "apprise-attachment" # Our filename extension when we can't otherwise determine one unknown_filename_extension = ".obj" # The strict argument is a flag specifying whether the list of known MIME # types is limited to only the official types registered with IANA. When # strict is True, only the IANA types are supported; when strict is False # (the default), some additional non-standard but commonly used MIME types # are also recognized. strict = False # The maximum file-size we will accept for an attachment size. If this is # set to zero (0), then no check is performed # 1 MB = 1048576 bytes # 5 MB = 5242880 bytes # 1 GB = 1048576000 bytes max_file_size = 1048576000 # By default all attachments types are inaccessible. # Developers of items identified in the attachment plugin directory # are requried to set a location location = ContentLocation.INACCESSIBLE # Here is where we define all of the arguments we accept on the url # such as: schema://whatever/?overflow=upstream&format=text # These act the same way as tokens except they are optional and/or # have default values set if mandatory. This rule must be followed template_args = { "cache": { "name": _("Cache Age"), "type": "int", # We default to (600) which means we cache for 10 minutes "default": 600, }, "mime": { "name": _("Forced Mime Type"), "type": "string", }, "name": { "name": _("Forced File Name"), "type": "string", }, "verify": { "name": _("Verify SSL"), # SSL Certificate Authority Verification "type": "bool", # Provide a default "default": True, }, } def __init__(self, name=None, mimetype=None, cache=None, **kwargs): """Initialize some general logging and common server arguments that will keep things consistent when working with the configurations that inherit this class. Optionally provide a filename to over-ride name associated with the actual file retrieved (from where-ever). The mime-type is automatically detected, but you can over-ride this by explicitly stating what it should be. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. """ super().__init__(**kwargs) if not mimetypes.inited: # Ensure mimetypes has been initialized mimetypes.init() # Attach Filename (does not have to be the same as path) self._name = name # The mime type of the attached content. This is detected if not # otherwise specified. self._mimetype = mimetype # The detected_mimetype, this is only used as a fallback if the # mimetype wasn't forced by the user self.detected_mimetype = None # The detected filename by calling child class. A detected filename # is always used if no force naming was specified. self.detected_name = None # Absolute path to attachment self.download_path = None # Track open file pointers self.__pointers = set() # Set our cache flag; it can be True, False, None, or a (positive) # integer... nothing else if cache is not None: try: self.cache = cache if isinstance(cache, bool) else int(cache) except (TypeError, ValueError): err = f"An invalid cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) from None # Some simple error checking if self.cache < 0: err = f"A negative cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) else: self.cache = None # Validate mimetype if specified if self._mimetype and ( next( ( t for t in mimetypes.types_map.values() if self._mimetype == t ), None, ) is None): err = f"An invalid mime-type ({mimetype}) was specified." self.logger.warning(err) raise TypeError(err) return @property def path(self): """Returns the absolute path to the filename. If this is not known or is know but has been considered expired (due to cache setting), then content is re-retrieved prior to returning. """ if not self.exists(): # we could not obtain our path return None return self.download_path @property def name(self): """Returns the filename.""" if self._name: # return our fixed content return self._name if not self.exists(): # we could not obtain our name return None if not self.detected_name: # If we get here, our download was successful but we don't have a # filename based on our content. ext = mimetypes.guess_extension(self.mimetype) self.detected_name = ( f"{self.unknown_filename}" f"{ext if ext else self.unknown_filename_extension}" ) return self.detected_name @property def mimetype(self): """Returns mime type (if one is present). Content is cached once determied to prevent overhead of future calls. """ if not self.exists(): # we could not obtain our attachment return None if self._mimetype: # return our pre-calculated cached content return self._mimetype if not self.detected_mimetype: # guess_type() returns: (type, encoding) and sets type to None # if it can't otherwise determine it. with contextlib.suppress(TypeError): # Directly reference _name and detected_name to prevent # recursion loop (as self.name calls this function) self.detected_mimetype, _ = mimetypes.guess_type( self._name if self._name else self.detected_name, strict=self.strict, ) # Return our mime type return ( self.detected_mimetype if self.detected_mimetype else self.unknown_mimetype ) def exists(self, retrieve_if_missing=True): """Simply returns true if the object has downloaded and stored the attachment AND the attachment has not expired.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False cache = ( self.template_args["cache"]["default"] if self.cache is None else self.cache ) try: if ( self.download_path and os.path.isfile(self.download_path) and cache ): # We have enough reason to look further into our cached content # and verify it has not expired. if cache is True: # return our fixed content as is; we will always cache it return True # Verify our cache time to determine whether we will get our # content again. age_in_sec = time.time() - os.stat(self.download_path).st_mtime if age_in_sec <= cache: return True except OSError: # The file is not present pass return False if not retrieve_if_missing else self.download() def base64(self, encoding="ascii"): """Returns the attachment object as a base64 string otherwise None is returned if an error occurs. If encoding is set to None, then it is not encoded when returned """ if not self: # We could not access the attachment self.logger.error( f"Could not access attachment {self.url(privacy=True)}." ) raise exception.AppriseFileNotFound("Attachment Missing") try: with self.open() as f: # Prepare our Attachment in Base64 return ( base64.b64encode(f.read()).decode(encoding) if encoding else base64.b64encode(f.read()) ) except (FileNotFoundError): # We no longer have a path to open raise exception.AppriseFileNotFound("Attachment Missing") from None except (TypeError, OSError) as e: self.logger.warning( "An I/O error occurred while reading {}.".format( self.name if self else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") raise exception.AppriseDiskIOError( "Attachment Access Error") from e def invalidate(self): """Release any temporary data that may be open by child classes. Externally fetched content should be automatically cleaned up when this function is called. This function should also reset the following entries to None: - detected_name : Should identify a human readable filename - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content """ # Remove all open pointers while self.__pointers: self.__pointers.pop().close() self.detected_name = None self.download_path = None self.detected_mimetype = None return def download(self): """This function must be over-ridden by inheriting classes. Inherited classes MUST populate: - detected_name: Should identify a human readable filename - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content If a download fails, you should ensure these values are set to None. """ raise NotImplementedError( "download() is implimented by the child class." ) def open(self, mode="rb"): """Return our file pointer and track it (we'll auto close later)""" pointer = open(self.path, mode=mode) # noqa: SIM115 self.__pointers.add(pointer) return pointer def chunk(self, size=5242880): """A Generator that yield chunks of a file with the specified size. By default the chunk size is set to 5MB (5242880 bytes) """ with self.open() as file: while True: chunk = file.read(size) if not chunk: break yield chunk def __enter__(self): """Support with keyword.""" return self.open() def __exit__(self, value_type, value, traceback): """Stub to do nothing; but support exit of with statement gracefully.""" return @staticmethod def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url( url, verify_host=verify_host, sanitize=sanitize ) if not results: # We're done; we failed to parse our url return results # Allow overriding the default config mime type if "mime" in results["qsd"]: results["mimetype"] = ( results["qsd"].get("mime", "").strip().lower() ) # Allow overriding the default file name if "name" in results["qsd"]: results["name"] = results["qsd"].get("name", "").strip().lower() # Our cache value if "cache" in results["qsd"]: # First try to get it's integer value try: results["cache"] = int(results["qsd"]["cache"]) except (ValueError, TypeError): # No problem, it just isn't an integer; now treat it as a bool # instead: results["cache"] = parse_bool(results["qsd"]["cache"]) return results def __len__(self): """Returns the filesize of the attachment.""" if not self: return 0 try: return os.path.getsize(self.path) if self.path else 0 except OSError: # OSError can occur if the file is inaccessible return 0 def __bool__(self): """Allows the Apprise object to be wrapped in an based 'if statement'. True is returned if our content was downloaded correctly. """ return bool(self.path) def __del__(self): """Perform any house cleaning.""" self.invalidate() ================================================ FILE: apprise/attachment/file.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import re from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..utils.disk import path_decode from .base import AttachBase class AttachFile(AttachBase): """A wrapper for File based attachment sources.""" # The default descriptive name associated with the service service_name = _("Local File") # The default protocol protocol = "file" # Content is local to the same location as the apprise instance # being called (server-side) location = ContentLocation.LOCAL def __init__(self, path, **kwargs): """Initialize Local File Attachment Object.""" super().__init__(**kwargs) # Store path but mark it dirty since we have not performed any # verification at this point. self.dirty_path = path_decode(path) # Track our file as it was saved self.__original_path = os.path.normpath(path) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self._mimetype: # A mime-type was enforced params["mime"] = self._mimetype if self._name: # A name was enforced params["name"] = self._name return "file://{path}{params}".format( path=self.quote(self.__original_path), params=( "?{}".format(self.urlencode(params, safe="/")) if params else "" ), ) def download(self, **kwargs): """Perform retrieval of our data. For file base attachments, our data already exists, so we only need to validate it. """ if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False # Ensure any existing content set has been invalidated self.invalidate() try: if not os.path.isfile(self.dirty_path): return False except OSError: return False if ( self.max_file_size > 0 and os.path.getsize(self.dirty_path) > self.max_file_size ): # The content to attach is to large self.logger.error( "Content exceeds allowable maximum file length" f" ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False # We're good to go if we get here. Set our minimum requirements of # a call do download() before returning a success self.download_path = self.dirty_path self.detected_name = os.path.basename(self.download_path) # We don't need to set our self.detected_mimetype as it can be # pulled at the time it's needed based on the detected_name return True @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = AttachBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results match = re.match(r"file://(?P[^?]+)(\?.*)?", url, re.I) if not match: return None results["path"] = AttachFile.unquote(match.group("path")) return results ================================================ FILE: apprise/attachment/http.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import os import re from tempfile import NamedTemporaryFile import threading import requests from ..common import ContentLocation from ..locale import gettext_lazy as _ from ..url import PrivacyMode from .base import AttachBase class AttachHTTP(AttachBase): """A wrapper for HTTP based attachment sources.""" # The default descriptive name associated with the service service_name = _("Web Based") # The default protocol protocol = "http" # The default secure protocol secure_protocol = "https" # The number of bytes in memory to read from the remote source at a time chunk_size = 8192 # Web based requests are remote/external to our current location location = ContentLocation.HOSTED # thread safe loading _lock = threading.Lock() def __init__(self, headers=None, **kwargs): """Initialize HTTP Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.schema = "https" if self.secure else "http" self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "/" self.headers = {} if headers: # Store our extra headers self.headers.update(headers) # Where our content is written to upon a call to download. self._temp_file = None # Our Query String Dictionary; we use this to track arguments # specified that aren't otherwise part of this class self.qsd = { k: v for k, v in kwargs.get("qsd", {}).items() if k not in self.template_args } return def download(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False # prepare header headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) auth = None if self.user: auth = (self.user, self.password) url = f"{self.schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath # Where our request object will temporarily live. r = None # Always call throttle before any remote server i/o is made self.throttle() with self._lock: if self.exists(retrieve_if_missing=False): # Due to locking; it's possible a concurrent thread already # handled the retrieval in which case we can safely move on self.logger.trace( "HTTP Attachment %s already retrieved", self._temp_file.name, ) return True # Ensure any existing content set has been invalidated self.invalidate() self.logger.debug( "HTTP Attachment Fetch URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) try: # Make our request with requests.get( url, headers=headers, auth=auth, params=self.qsd, verify=self.verify_certificate, timeout=self.request_timeout, stream=True, ) as r: # Handle Errors r.raise_for_status() # Get our file-size (if known) try: file_size = int(r.headers.get("Content-Length", "0")) except (TypeError, ValueError): # Handle edge case where Content-Length is a bad value file_size = 0 # Perform a little Q/A on file limitations and restrictions if ( self.max_file_size > 0 and file_size > self.max_file_size ): # The content retrieved is to large self.logger.error( "HTTP response exceeds allowable maximum file" f" length ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False # Detect config format based on mime if the format isn't # already enforced self.detected_mimetype = r.headers.get("Content-Type") d = r.headers.get("Content-Disposition", "") result = re.search( r"filename=['\"]?(?P[^'\"]+)['\"]?", d, re.I ) if result: self.detected_name = result.group("name").strip() # Create a temporary file to work with; delete must be set # to False or it isn't compatible with Microsoft Windows # instances. In lieu of this, __del__ will clean up the # file for us. self._temp_file = \ NamedTemporaryFile(delete=False) # noqa: SIM115 # Get our chunk size chunk_size = self.chunk_size # Track all bytes written to disk bytes_written = 0 # If we get here, we can now safely write our content to # disk for chunk in r.iter_content(chunk_size=chunk_size): # filter out keep-alive chunks if chunk: self._temp_file.write(chunk) bytes_written = self._temp_file.tell() # Prevent a case where Content-Length isn't # provided. In this case we don't want to fetch # beyond our limits if self.max_file_size > 0: if bytes_written > self.max_file_size: # The content retrieved is to large self.logger.error( "HTTP response exceeds allowable" " maximum file length" f" ({int(self.max_file_size / 1024)}" f"KB): {self.url(privacy=True)}" ) # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False elif ( bytes_written + chunk_size > self.max_file_size ): # Adjust out next read to accommodate up to # our limit +1. This will prevent us from # reading to much into our memory buffer self.max_file_size - bytes_written + 1 # Ensure our content is flushed to disk for post-processing self._temp_file.flush() # Set our minimum requirements for a successful download() # call self.download_path = self._temp_file.name if not self.detected_name: self.detected_name = os.path.basename(self.fullpath) except requests.RequestException as e: self.logger.error( "A Connection error occurred retrieving HTTP " f"configuration from {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False except OSError: # IOError is present for backwards compatibility with Python # versions older then 3.3. >= 3.3 throw OSError now. # Could not open and/or write the temporary file self.logger.error( "Could not write attachment to disk:" f" {self.url(privacy=True)}" ) # Invalidate any variables previously set self.invalidate() # Return False (signifying a failure) return False # Return our success return True def invalidate(self): """Close our temporary file.""" if self._temp_file: self.logger.trace("Attachment cleanup of %s", self._temp_file.name) self._temp_file.close() with contextlib.suppress(OSError): # Ensure our file is removed (if it exists) os.unlink(self._temp_file.name) # Reset our temporary file to prevent from entering # this block again self._temp_file = None super().invalidate() def __del__(self): """Tidy memory if open.""" self.invalidate() def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) # Prepare our cache value if self.cache is not None: if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Set our cache value params["cache"] = cache if self._mimetype: # A format was enforced params["mime"] = self._mimetype if self._name: # A name was enforced params["name"] = self._name # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Apply any remaining entries to our URL params.update(self.qsd) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=self.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=self.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, hostname=self.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=self.quote(self.fullpath, safe="/"), params=self.urlencode(params, safe="/"), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = AttachBase.parse_url(url, sanitize=False) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set results["headers"] = results["qsd-"] results["headers"].update(results["qsd+"]) return results ================================================ FILE: apprise/attachment/memory.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import io import os import re import uuid from .. import exception from ..common import ContentLocation from ..locale import gettext_lazy as _ from .base import AttachBase class AttachMemory(AttachBase): """A wrapper for Memory based attachment sources.""" # The default descriptive name associated with the service service_name = _("Memory") # The default protocol protocol = "memory" # Content is local to the same location as the apprise instance # being called (server-side) location = ContentLocation.LOCAL def __init__( self, content=None, name=None, mimetype=None, encoding="utf-8", **kwargs, ): """Initialize Memory Based Attachment Object.""" # Create our BytesIO object self._data = io.BytesIO() if content is None: # Empty; do nothing pass elif isinstance(content, str): content = content.encode(encoding) if mimetype is None: mimetype = "text/plain" if not name: # Generate a unique filename name = str(uuid.uuid4()) + ".txt" elif not isinstance(content, bytes): raise TypeError( "Provided content for memory attachment is invalid" ) # Store our content if content: self._data.write(content) if mimetype is None: # Default mimetype mimetype = "application/octet-stream" if not name: # Generate a unique filename name = str(uuid.uuid4()) + ".dat" # Initialize our base object super().__init__(name=name, mimetype=mimetype, **kwargs) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "mime": self._mimetype, } return "memory://{name}?{params}".format( name=self.quote(self._name), params=self.urlencode(params, safe="/"), ) def open(self, *args, **kwargs): """Return our memory object.""" # Return our object self._data.seek(0, 0) return self._data def __enter__(self): """Support with clause.""" # Return our object self._data.seek(0, 0) return self._data def download(self, **kwargs): """Handle memory download() call.""" if self.location == ContentLocation.INACCESSIBLE: # our content is inaccessible return False if self.max_file_size > 0 and len(self) > self.max_file_size: # The content to attach is to large self.logger.error( "Content exceeds allowable maximum memory size" f" ({int(self.max_file_size / 1024)}KB):" f" {self.url(privacy=True)}" ) # Return False (signifying a failure) return False return True def base64(self, encoding="ascii"): """We need to over-ride this since the base64 sub-library seems to close our file descriptor making it no longer referencable.""" if not self: # We could not access the attachment self.logger.error( f"Could not access attachment {self.url(privacy=True)}." ) raise exception.AppriseFileNotFound("Attachment Missing") self._data.seek(0, 0) return ( base64.b64encode(self._data.read()).decode(encoding) if encoding else base64.b64encode(self._data.read()) ) def invalidate(self): """Removes data.""" self._data.truncate(0) return def exists(self): """Over-ride exists() call.""" size = len(self) return bool( self.location != ContentLocation.INACCESSIBLE and size > 0 and ( self.max_file_size <= 0 or (self.max_file_size > 0 and size <= self.max_file_size) ) ) @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = AttachBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results if "name" not in results: # Allow fall-back to be from URL match = re.match(r"memory://(?P[^?]+)(\?.*)?", url, re.I) if match: # Store our filename only (ignore any defined paths) results["name"] = os.path.basename( AttachMemory.unquote(match.group("path")) ) return results @property def path(self): """Return the filename.""" if not self.exists(): # we could not obtain our path return None return self._name def __len__(self): """Returns the size of he memory attachment.""" return self._data.getbuffer().nbytes def __bool__(self): """Allows the Apprise object to be wrapped in an based 'if statement'. True is returned if our content was downloaded correctly. """ return self.exists() ================================================ FILE: apprise/cli.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import os from os.path import exists, isfile import platform import re import shutil import sys import textwrap import click from . import ( Apprise, AppriseAsset, AppriseConfig, PersistentStore, __copyright__, __license__, __title__, __version__, ) from .common import ( NOTIFY_FORMATS, NOTIFY_TYPES, PERSISTENT_STORE_MODES, ContentLocation, NotifyFormat, NotifyType, PersistentStoreMode, PersistentStoreState, ) from .logger import logger from .utils.disk import bytes_to_str, dir_size, path_decode from .utils.parse import parse_list # By default we allow looking 1 level down recursively in Apprise configuration # files. DEFAULT_RECURSION_DEPTH = 1 # Default number of days to prune persistent storage DEFAULT_STORAGE_PRUNE_DAYS = int( os.environ.get("APPRISE_STORAGE_PRUNE_DAYS", 30) ) # The default URL ID Length DEFAULT_STORAGE_UID_LENGTH = int( os.environ.get("APPRISE_STORAGE_UID_LENGTH", 8) ) # Defines the environment variable to parse if defined. This is ONLY # Referenced if: # - No Configuration Files were found/loaded/specified # - No URLs were provided directly into the CLI Call DEFAULT_ENV_APPRISE_URLS = "APPRISE_URLS" # Defines the override path for the configuration files read DEFAULT_ENV_APPRISE_CONFIG_PATH = "APPRISE_CONFIG_PATH" # Defines the override path for the plugins to load DEFAULT_ENV_APPRISE_PLUGIN_PATH = "APPRISE_PLUGIN_PATH" # Defines the override path for the persistent storage DEFAULT_ENV_APPRISE_STORAGE_PATH = "APPRISE_STORAGE_PATH" # Defines our click context settings adding -h to the additional options that # can be specified to get the help menu to come up CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} # Define our default configuration we use if nothing is otherwise specified DEFAULT_CONFIG_PATHS = ( # Legacy Path Support "~/.apprise", "~/.apprise.conf", "~/.apprise.yml", "~/.apprise.yaml", "~/.config/apprise", "~/.config/apprise.conf", "~/.config/apprise.yml", "~/.config/apprise.yaml", # Plugin Support Extended Directory Search Paths "~/.apprise/apprise", "~/.apprise/apprise.conf", "~/.apprise/apprise.yml", "~/.apprise/apprise.yaml", "~/.config/apprise/apprise", "~/.config/apprise/apprise.conf", "~/.config/apprise/apprise.yml", "~/.config/apprise/apprise.yaml", # Global Configuration File Support "/etc/apprise", "/etc/apprise.yml", "/etc/apprise.yaml", "/etc/apprise/apprise", "/etc/apprise/apprise.conf", "/etc/apprise/apprise.yml", "/etc/apprise/apprise.yaml", ) # Define our paths to search for plugins DEFAULT_PLUGIN_PATHS = ( "~/.apprise/plugins", "~/.config/apprise/plugins", # Global Plugin Support "/var/lib/apprise/plugins", ) # # General Options and Defaults # DEFAULT_NOTIFY_TYPE = NotifyType.INFO NOTIFY_TYPE_CHOICES: tuple[NotifyType, ...] = ( NotifyType.INFO, NotifyType.SUCCESS, NotifyType.WARNING, NotifyType.FAILURE, ) DEFAULT_NOTIFY_FORMAT = NotifyFormat.TEXT NOTIFY_FORMAT_CHOICES: tuple[NotifyFormat, ...] = ( NotifyFormat.TEXT, NotifyFormat.MARKDOWN, NotifyFormat.HTML, ) # # Persistent Storage # DEFAULT_STORAGE_PATH = "~/.local/share/apprise/cache" # Storage Mode DEFAULT_STORAGE_MODE = PersistentStoreMode.AUTO # Create an ordered list of options (first is default) PERSISTENT_STORE_MODE_CHOICES: tuple[PersistentStoreMode, ...] = ( PersistentStoreMode.AUTO, PersistentStoreMode.FLUSH, PersistentStoreMode.MEMORY, ) # Detect Windows if platform.system() == "Windows": # Default Config Search Path for Windows Users DEFAULT_CONFIG_PATHS = ( "%APPDATA%\\Apprise\\apprise", "%APPDATA%\\Apprise\\apprise.conf", "%APPDATA%\\Apprise\\apprise.yml", "%APPDATA%\\Apprise\\apprise.yaml", "%LOCALAPPDATA%\\Apprise\\apprise", "%LOCALAPPDATA%\\Apprise\\apprise.conf", "%LOCALAPPDATA%\\Apprise\\apprise.yml", "%LOCALAPPDATA%\\Apprise\\apprise.yaml", # # Global Support # # C:\ProgramData\Apprise "%ALLUSERSPROFILE%\\Apprise\\apprise", "%ALLUSERSPROFILE%\\Apprise\\apprise.conf", "%ALLUSERSPROFILE%\\Apprise\\apprise.yml", "%ALLUSERSPROFILE%\\Apprise\\apprise.yaml", # C:\Program Files\Apprise "%PROGRAMFILES%\\Apprise\\apprise", "%PROGRAMFILES%\\Apprise\\apprise.conf", "%PROGRAMFILES%\\Apprise\\apprise.yml", "%PROGRAMFILES%\\Apprise\\apprise.yaml", # C:\Program Files\Common Files "%COMMONPROGRAMFILES%\\Apprise\\apprise", "%COMMONPROGRAMFILES%\\Apprise\\apprise.conf", "%COMMONPROGRAMFILES%\\Apprise\\apprise.yml", "%COMMONPROGRAMFILES%\\Apprise\\apprise.yaml", ) # Default Plugin Search Path for Windows Users DEFAULT_PLUGIN_PATHS = ( "%APPDATA%\\Apprise\\plugins", "%LOCALAPPDATA%\\Apprise\\plugins", # # Global Support # # C:\ProgramData\Apprise\plugins "%ALLUSERSPROFILE%\\Apprise\\plugins", # C:\Program Files\Apprise\plugins "%PROGRAMFILES%\\Apprise\\plugins", # C:\Program Files\Common Files "%COMMONPROGRAMFILES%\\Apprise\\plugins", ) # # Persistent Storage # DEFAULT_STORAGE_PATH = "%APPDATA%/Apprise/cache" class PersistentStorageMode: """Persistent Storage Modes.""" # List all detected configuration loaded LIST = "list" # Prune persistent storage based on age PRUNE = "prune" # Reset all (regardless of age) CLEAR = "clear" # Define the types in a list for validation purposes PERSISTENT_STORAGE_MODES = ( PersistentStorageMode.LIST, PersistentStorageMode.PRUNE, PersistentStorageMode.CLEAR, ) if os.environ.get("APPRISE_STORAGE_PATH", "").strip(): # Override Default Storage Path DEFAULT_STORAGE_PATH = os.environ.get("APPRISE_STORAGE_PATH") def print_version_msg(): """Prints version message when -V or --version is specified.""" result = [] result.append(f"{__title__} v{__version__}") result.append(__copyright__) result.append(f"This code is licensed under the {__license__} License.") click.echo("\n".join(result)) class CustomHelpCommand(click.Command): def format_help(self, ctx, formatter): formatter.write_text("Usage:") formatter.write_text( " apprise [OPTIONS] [APPRISE_URL [APPRISE_URL2 [APPRISE_URL3]]]" ) formatter.write_text( " apprise storage [OPTIONS] [ACTION] [UID1 [UID2 [UID3]]]" ) # Custom help message formatter.write_text("") content = ( ( "Send a notification to all of the specified servers " "identified by their URLs" ), ( "the content provided within the title, body and " "notification-type." ), "", ( "For a list of all of the supported services and information" " on how to use " ), "them, check out https://github.com/caronc/apprise", ) for line in content: formatter.write_text(line) # Display options and arguments in the default format self.format_options(ctx, formatter) self.format_epilog(ctx, formatter) # Custom 'Actions:' section after the 'Options:' formatter.write_text("") formatter.write_text("Actions:") actions = [( "storage", "Access the persistent storage disk administration", [ ( "list", ( "List all URL IDs associated with detected URL(s)." " This is also the default action run if nothing is" " provided" ), ), ( "prune", ( "Eliminates stale entries found based on " "--storage-prune-days (-SPD)" ), ), ( "clean", "Removes any persistent data created by Apprise", ), ], )] # # Some variables # # actions are indented this many spaces # sub actions double this value action_indent = 2 # label padding (for alignment) action_label_width = 10 space = " " space_re = re.compile(r"\r*\n") cols = 80 indent = 10 # Format each action and its subactions for action, description, sub_actions in actions: # Our action indent ai = " " * action_indent # Format the main action description formatted_description = space_re.split( textwrap.fill( description, width=(cols - indent - action_indent), initial_indent=space * indent, subsequent_indent=space * indent, ) ) for no, line in enumerate(formatted_description): if not no: formatter.write_text( f"{ai}{action:<{action_label_width}}{line}" ) else: # pragma: no cover # Note: no branch is set intentionally since this is not # tested since in 2025.08.13 when this was set up # it never entered this area of the code. But we # know it works because we repeat this process with # our sub-options below formatter.write_text( f"{ai}{space:<{action_label_width}}{line}" ) # Format each subaction ai = " " * (action_indent * 2) for action, description in sub_actions: formatted_description = space_re.split( textwrap.fill( description, width=(cols - indent - (action_indent * 3)), initial_indent=space * (indent - action_indent), subsequent_indent=space * (indent - action_indent), ) ) for no, line in enumerate(formatted_description): if not no: formatter.write_text( f"{ai}{action:<{action_label_width}}{line}" ) else: formatter.write_text( f"{ai}{space:<{action_label_width}}{line}" ) # Include any epilog or additional text self.format_epilog(ctx, formatter) @click.command(context_settings=CONTEXT_SETTINGS, cls=CustomHelpCommand) @click.option( "--body", "-b", default=None, type=str, help=( "Specify the message body. If no body is specified then " "content is read from ." ), ) @click.option( "--title", "-t", default=None, type=str, help="Specify the message title. This field is completely optional.", ) @click.option( "--plugin-path", "-P", default=None, type=str, multiple=True, metavar="PATH", help="Specify one or more plugin paths to scan.", ) @click.option( "--storage-path", "-S", default=DEFAULT_STORAGE_PATH, type=str, metavar="PATH", help=( "Specify the path to the persistent storage location " f"(default={DEFAULT_STORAGE_PATH})." ), ) @click.option( "--storage-prune-days", "-SPD", default=DEFAULT_STORAGE_PRUNE_DAYS, type=int, help=( "Define the number of days the storage prune should run using." " Setting this to zero (0) will eliminate all accumulated content. By" f" default this value is {DEFAULT_STORAGE_PRUNE_DAYS} days." ), ) @click.option( "--storage-uid-length", "-SUL", default=DEFAULT_STORAGE_UID_LENGTH, type=int, help=( "Define the number of unique characters to store persistent cache in." f" By default this value is {DEFAULT_STORAGE_UID_LENGTH} characters." ), ) @click.option( "--storage-mode", "-SM", default=DEFAULT_STORAGE_MODE.value, type=str, metavar="MODE", help=( "Specify the persistent storage operational mode " f'(default={DEFAULT_STORAGE_MODE.value}). ' 'Possible values are: "{}".'.format( '", "'.join(mode.value for mode in PERSISTENT_STORE_MODE_CHOICES) ) ), ) @click.option( "--config", "-c", default=None, type=str, multiple=True, metavar="CONFIG_URL", help="Specify one or more configuration locations.", ) @click.option( "--attach", "-a", default=None, type=str, multiple=True, metavar="ATTACHMENT_URL", help="Specify one or more attachments.", ) @click.option( "--notification-type", "-n", default=DEFAULT_NOTIFY_TYPE.value, type=str, metavar="TYPE", help=( f"Specify the message type (default={DEFAULT_NOTIFY_TYPE.value}). " 'Possible values are: "{}".'.format( '", "'.join(nt.value for nt in NOTIFY_TYPE_CHOICES) ) ), ) @click.option( "--input-format", "-i", default=DEFAULT_NOTIFY_FORMAT.value, type=str, metavar="FORMAT", help=( f"Specify the message input format " f"(default={DEFAULT_NOTIFY_FORMAT.value}). " 'Possible values are: "{}".'.format( '", "'.join(fmt.value for fmt in NOTIFY_FORMAT_CHOICES) ) ), ) @click.option( "--theme", "-T", default="default", type=str, metavar="THEME", help="Specify the default theme.", ) @click.option( "--tag", "-g", default=None, type=str, multiple=True, metavar="TAG", help=( "Specify one or more tags to filter which services to notify. Use " "multiple --tag (-g) entries to match ANY tag. Use comma separators " "to require ALL tags (strict match). Omit to notify untagged services " 'only, or use "all" to notify everything.' ), ) @click.option( "--disable-async", "-Da", is_flag=True, help="Send all notifications sequentially", ) @click.option( "--dry-run", "-d", is_flag=True, help=( "Perform a trial run but only prints the notification " "services to-be triggered to stdout. Notifications are never " "sent using this mode." ), ) @click.option( "--details", "-l", is_flag=True, help="Prints details about the current services supported by Apprise.", ) @click.option( "--recursion-depth", "-R", default=DEFAULT_RECURSION_DEPTH, type=int, help=( "The number of recursive import entries that can be " "loaded from within Apprise configuration. By default " f"this is set to {DEFAULT_RECURSION_DEPTH}." ), ) @click.option( "--verbose", "-v", count=True, help=( "Makes the operation more talkative. Use multiple v to " "increase the verbosity. I.e.: -vvvv" ), ) @click.option( "--interpret-escapes", "-e", is_flag=True, help="Enable interpretation of backslash escapes", ) @click.option( "--interpret-emojis", "-j", is_flag=True, help="Enable interpretation of :emoji: definitions", ) @click.option("--debug", "-D", is_flag=True, help="Debug mode") @click.option( "--version", "-V", is_flag=True, help="Display the apprise version and exit.", ) @click.argument( "urls", nargs=-1, metavar="SERVER_URL [SERVER_URL2 [SERVER_URL3]]", ) @click.pass_context def main( ctx, body, title, config, attach, urls, notification_type, theme, tag, input_format, dry_run, recursion_depth, verbose, disable_async, details, interpret_escapes, interpret_emojis, plugin_path, storage_path, storage_mode, storage_prune_days, storage_uid_length, debug, version, ): """Send a notification to all of the specified servers identified by their URLs the content provided within the title, body and notification-type. For a list of all of the supported services and information on how to use them, check out https://github.com/caronc/apprise """ # Note: Click ignores the return values of functions it wraps, If you # want to return a specific error code, you must call ctx.exit() # as you will see below. debug = bool(debug) if debug: # Verbosity must be a minimum of 3 verbose = 3 if verbose < 3 else verbose # Logging ch = logging.StreamHandler(sys.stdout) if verbose > 3: # -vvvv: Most Verbose Debug Logging logger.setLevel(logging.TRACE) elif verbose > 2: # -vvv: Debug Logging logger.setLevel(logging.DEBUG) elif verbose > 1: # -vv: INFO Messages logger.setLevel(logging.INFO) elif verbose > 0: # -v: WARNING Messages logger.setLevel(logging.WARNING) else: # No verbosity means we display ERRORS only AND any deprecation # warnings logger.setLevel(logging.ERROR) # Format our logger formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") ch.setFormatter(formatter) logger.addHandler(ch) # Update our asyncio logger asyncio_logger = logging.getLogger("asyncio") for handler in logger.handlers: asyncio_logger.addHandler(handler) asyncio_logger.setLevel(logger.level) if version: print_version_msg() ctx.exit(0) # Simple Error Checking notification_type = notification_type.strip().lower() if notification_type not in NOTIFY_TYPES: click.echo( f"The --notification-type (-n) value of {notification_type} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) input_format = input_format.strip().lower() if input_format not in NOTIFY_FORMATS: click.echo( f"The --input-format (-i) value of {input_format} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) storage_mode = storage_mode.strip().lower() if storage_mode not in PERSISTENT_STORE_MODES: click.echo( f"The --storage-mode (-SM) value of {storage_mode} is not" " supported." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 ctx.exit(2) # # Apply Environment Overrides if defined # config_paths = DEFAULT_CONFIG_PATHS if "APPRISE_CONFIG" in os.environ: # Deprecate (this was from previous versions of Apprise <= 1.9.1) logger.deprecate( "APPRISE_CONFIG environment variable has been changed to " f"{DEFAULT_ENV_APPRISE_CONFIG_PATH}" ) logger.debug( "Loading provided APPRISE_CONFIG (deprecated) environment variable" ) config_paths = (os.environ.get("APPRISE_CONFIG", "").strip(),) elif DEFAULT_ENV_APPRISE_CONFIG_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_CONFIG_PATH} " "environment variable" ) config_paths = re.split( r"[\r\n;]+", os.environ.get(DEFAULT_ENV_APPRISE_CONFIG_PATH).strip(), ) plugin_paths_ = DEFAULT_PLUGIN_PATHS if DEFAULT_ENV_APPRISE_PLUGIN_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_PLUGIN_PATH} environment " "variable" ) plugin_paths_ = re.split( r"[\r\n;]+", os.environ.get(DEFAULT_ENV_APPRISE_PLUGIN_PATH).strip(), ) if DEFAULT_ENV_APPRISE_STORAGE_PATH in os.environ: logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_STORAGE_PATH} environment " "variable" ) storage_path = os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH).strip() # # Continue with initialization process # # Prepare a default set of plugin paths to scan; anything specified # on the CLI always trumps plugin_paths = ( plugin_path if plugin_path else [path for path in plugin_paths_ if exists(path_decode(path))] ) if storage_uid_length < 2: click.echo( "The --storage-uid-length (-SUL) value can not be lower " "than two (2)." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a # parameter issue. For consistency, we also return a 2 ctx.exit(2) # Prepare our asset asset = AppriseAsset( # Our body format body_format=input_format, # Interpret Escapes interpret_escapes=interpret_escapes, # Interpret Emojis interpret_emojis=None if not interpret_emojis else True, # Set the theme theme=theme, # Async mode allows a user to send all of their notifications # asynchronously. This was made an option incase there are problems # in the future where it is better that everything runs sequentially/ # synchronously instead. async_mode=disable_async is not True, # Load our plugins plugin_paths=plugin_paths, # Load our persistent storage path storage_path=path_decode(storage_path), # Our storage URL ID Length storage_idlen=storage_uid_length, # Define if we flush to disk as soon as possible or not when required storage_mode=storage_mode, ) # Create our Apprise object a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL) # Track if we are performing a storage action storage_action = bool(urls and "storage".startswith(urls[0])) if details: # Print details and exit results = a.details(show_requirements=True, show_disabled=True) # Sort our results: plugins = sorted( results["schemas"], key=lambda i: str(i["service_name"]) ) for entry in plugins: protocols = ( [] if not entry["protocols"] else [p for p in entry["protocols"] if isinstance(p, str)] ) protocols.extend( [] if not entry["secure_protocols"] else [ p for p in entry["secure_protocols"] if isinstance(p, str) ] ) if len(protocols) == 1: # Simplify view by swapping {schema} with the single # protocol value # Convert tuple to list entry["details"]["templates"] = list( entry["details"]["templates"] ) for x in range(len(entry["details"]["templates"])): entry["details"]["templates"][x] = re.sub( r"^[^}]+}://", f"{protocols[0]}://", entry["details"]["templates"][x], ) fg = "green" if entry["enabled"] else "red" if entry["category"] == "custom": # Identify these differently fg = "cyan" # Flip the enable switch so it forces the requirements # to be displayed entry["enabled"] = False click.echo( click.style( "{} {:<30} ".format( "+" if entry["enabled"] else "-", str(entry["service_name"]), ), fg=fg, bold=True, ), nl=(not entry["enabled"] or len(protocols) == 1), ) if not entry["enabled"]: if entry["requirements"]["details"]: click.echo(" " + str(entry["requirements"]["details"])) if entry["requirements"]["packages_required"]: click.echo(" Python Packages Required:") for req in entry["requirements"]["packages_required"]: click.echo(" - " + req) if entry["requirements"]["packages_recommended"]: click.echo(" Python Packages Recommended:") for req in entry["requirements"]["packages_recommended"]: click.echo(" - " + req) # new line padding between entries if entry["category"] == "native": click.echo() continue if len(protocols) > 1: click.echo( "| Schema(s): {}".format( ", ".join(protocols), ) ) prefix = " - " click.echo( "{}{}".format( prefix, f"\n{prefix}".join(entry["details"]["templates"]) ) ) # new line padding between entries click.echo() ctx.exit(0) # end if details() # The priorities of what is accepted are parsed in order below: # 1. URLs by command line # 2. Configuration by command line # 3. URLs by environment variable: APPRISE_URLS # 4. Default Configuration File(s) # elif urls and not storage_action: if tag: # Ignore any tags specified logger.warning( "--tag (-g) entries are ignored when using specified URLs" ) tag = None # Load our URLs (if any defined) for url in urls: a.add(url) if config: # Provide a warning to the end user if they specified both logger.warning( "You defined both URLs and a --config (-c) entry; " "Only the URLs will be referenced." ) elif config: # We load our configuration file(s) now only if no URLs were specified # Specified config entries trump all a.add( AppriseConfig(paths=config, asset=asset, recursion=recursion_depth) ) elif os.environ.get(DEFAULT_ENV_APPRISE_URLS, "").strip(): logger.debug( f"Loading provided {DEFAULT_ENV_APPRISE_URLS} environment variable" ) if tag: # Ignore any tags specified logger.warning( "--tag (-g) entries are ignored when using specified URLs" ) tag = None # Attempt to use our APPRISE_URLS environment variable (if populated) a.add(os.environ[DEFAULT_ENV_APPRISE_URLS].strip()) else: # Load default configuration a.add( AppriseConfig( paths=[f for f in config_paths if isfile(path_decode(f))], asset=asset, recursion=recursion_depth, ) ) if not dry_run and not (a or storage_action): click.echo( "You must specify at least one server URL or populated " "configuration file." ) click.echo("Try 'apprise --help' for more information.") ctx.exit(1) # each --tag entry comprises of a comma separated 'and' list # we or each of of the --tag and sets specified. tags = None if not tag else [parse_list(t) for t in tag] # Determine if we're dealing with URLs or url_ids based on the first # entry provided. if storage_action: # # Storage Mode # - urls are now to be interpreted as best matching namespaces # if storage_prune_days < 0: click.echo( "The --storage-prune-days (-SPD) value can not be lower " "than zero (0)." ) click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a # parameter issue. For consistency, we also return a 2 ctx.exit(2) # Number of columns to assume in the terminal. In future, maybe this # can be detected and made dynamic. The actual column count is 80, but # 5 characters are already reserved for the counter on the left (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) # Pop 'storage' off of the head of our list filter_uids = urls[1:] action = PERSISTENT_STORAGE_MODES[0] if filter_uids: action_ = next( # pragma: no branch ( a for a in PERSISTENT_STORAGE_MODES if a.startswith(filter_uids[0]) ), None, ) if action_: # pop 'action' off the head of our list filter_uids = filter_uids[1:] action = action_ # Get our detected URL IDs uids = {} for plugin in a if not tags else a.find(tag=tags): id_ = plugin.url_id() if not id_: continue if filter_uids and next( (False for n in filter_uids if id_.startswith(n)), True ): continue if id_ not in uids: uids[id_] = { "plugins": [plugin], "state": PersistentStoreState.UNUSED.value, "size": 0, } else: # It's possible to have more than one URL point to the same # location (thus match against the same url id more than once uids[id_]["plugins"].append(plugin) if action == PersistentStorageMode.LIST: detected_uid = PersistentStore.disk_scan( # Use our asset path as it has already been properly parsed path=asset.storage_path, # Provide filter if specified namespace=filter_uids, ) for id_ in detected_uid: size, _ = dir_size(os.path.join(asset.storage_path, id_)) if id_ in uids: uids[id_]["state"] = PersistentStoreState.ACTIVE.value uids[id_]["size"] = size elif not tags: uids[id_] = { "plugins": [], # No cross reference (wasted space?) "state": PersistentStoreState.STALE.value, # Acquire disk space "size": size, } for idx, (uid, meta) in enumerate(uids.items()): fg = ( "green" if meta["state"] == PersistentStoreState.ACTIVE.value else ( "red" if meta["state"] == PersistentStoreState.STALE.value else "white" ) ) if idx > 0: # New line click.echo() click.echo(f"{idx + 1: 4d}. ", nl=False) click.echo( click.style( "{:<52} {:<8} {}".format( uid, bytes_to_str(meta["size"]), meta["state"] ), fg=fg, bold=True, ) ) for entry in meta["plugins"]: url = entry.url(privacy=True) click.echo( "{:>7} {}".format( "-", ( url if len(url) <= (columns - 8) else f"{url[:columns - 11]}..." ), ) ) if entry.tags: click.echo( "{:>10}: {}".format("tags", ", ".join(entry.tags)) ) else: # PersistentStorageMode.PRUNE or PersistentStorageMode.CLEAR if action == PersistentStorageMode.CLEAR: storage_prune_days = 0 # clean up storage results = PersistentStore.disk_prune( # Use our asset path as it has already been properly parsed path=asset.storage_path, # Provide our namespaces if they exist namespace=filter_uids if filter_uids else None, # Convert expiry from days to seconds expires=storage_prune_days * 60 * 60 * 24, action=not dry_run, ) ctx.exit(0) # end if disk_prune() ctx.exit(0) # end if storage() if not dry_run: if body is None: logger.trace("No --body (-b) specified; reading from stdin") # if no body was specified, then read from STDIN body = click.get_text_stream("stdin").read() # now print it out result = a.notify( body=body, title=title, notify_type=notification_type, tag=tags, attach=attach, ) else: # Number of columns to assume in the terminal. In future, maybe this # can be detected and made dynamic. The actual column count is 80, but # 5 characters are already reserved for the counter on the left (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) # Initialize our URL response; This is populated within the for/loop # below; but plays a factor at the end when we need to determine if # we iterated at least once in the loop. url = None for idx, server in enumerate(a.find(tag=tags)): url = server.url(privacy=True) click.echo( "{: 4d}. {}".format( idx + 1, ( url if len(url) <= (columns - 8) else f"{url[:columns - 9]}..." ), ) ) # Share our URL ID click.echo( "{:>10}: {}".format( "uid", "- n/a -" if not server.url_id() else server.url_id(), ) ) if server.tags: click.echo("{:>10}: {}".format("tags", ", ".join(server.tags))) # Initialize a default response of nothing matched, otherwise # if we matched at least one entry, we can return True result = None if url is None else True if result is None: # There were no notifications set. This is a result of just having # empty configuration files and/or being to restrictive when filtering # by specific tag(s) # Exit code 3 is used since Click uses exit code 2 if there is an # error with the parameters specified ctx.exit(3) elif result is False: # At least 1 notification service failed to send ctx.exit(1) # else: We're good! ctx.exit(0) ================================================ FILE: apprise/common.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from enum import Enum # isoformat is spelled out for compatibility with Python v3.6 AWARE_DATE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" NAIVE_DATE_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" class NotifyType(str, Enum): """A simple mapping of notification types most commonly used with all types of logging and notification services.""" INFO = "info" SUCCESS = "success" WARNING = "warning" FAILURE = "failure" # Define our types so we can verify if we need to NOTIFY_TYPES: frozenset[str] = frozenset(e.value for e in NotifyType) class NotifyImageSize(str, Enum): """A list of pre-defined image sizes to make it easier to work with defined plugins.""" XY_32 = "32x32" XY_72 = "72x72" XY_128 = "128x128" XY_256 = "256x256" # Define our image sizes so we can verify if we need to NOTIFY_IMAGE_SIZES: frozenset[str] = \ frozenset(e.value for e in NotifyImageSize) class NotifyFormat(str, Enum): """A list of pre-defined text message formats that can be passed via the apprise library.""" TEXT = "text" HTML = "html" MARKDOWN = "markdown" # Define our formats so we can verify if we need to NOTIFY_FORMATS: frozenset[str] = frozenset(e.value for e in NotifyFormat) class OverflowMode(str, Enum): """A list of pre-defined modes of how to handle the text when it exceeds the defined maximum message size.""" # Send the data as is; untouched. Let the upstream server decide how the # content is handled. Some upstream services might gracefully handle this # with expected intentions; others might not. UPSTREAM = "upstream" # Always truncate the text when it exceeds the maximum message size and # send it anyway TRUNCATE = "truncate" # Split the message into multiple smaller messages that fit within the # limits of what is expected. The smaller messages are sent SPLIT = "split" # Define our modes so we can verify if we need to OVERFLOW_MODES: frozenset[str] = frozenset(e.value for e in OverflowMode) class ConfigFormat(str, Enum): """A list of pre-defined config formats that can be passed via the apprise library.""" # A text based configuration. This consists of a list of URLs delimited by # a new line. pound/hashtag (#) or semi-colon (;) can be used as comment # characters. TEXT = "text" # YAML files allow a more rich of an experience when settig up your # apprise configuration files. YAML = "yaml" # Define our configuration formats mostly used for verification CONFIG_FORMATS: frozenset[str] = frozenset(e.value for e in ConfigFormat) class ContentIncludeMode(str, Enum): """The different Content inclusion modes. All content based plugins will have one of these associated with it. """ # - Content inclusion of same type only; hence a file:// can include # a file:// # - Cross file inclusion is not allowed unless insecure_includes (a flag) # is set to True. In these cases STRICT acts as type ALWAYS STRICT = "strict" # This content type can never be included NEVER = "never" # This content can always be included ALWAYS = "always" # Define our file inclusion types so we can verify if we need to CONTENT_INCLUDE_MODES: frozenset[str] = \ frozenset(e.value for e in ContentIncludeMode) class ContentLocation(str, Enum): """This is primarily used for handling file attachments. The idea is to track the source of the attachment itself. We don't want remote calls to a server to access local attachments for example. By knowing the attachment type and cross-associating it with how we plan on accessing the content, we can make a judgement call (for security reasons) if we will allow it. Obviously local uses of apprise can access both local and remote type files. """ # Content is located locally (on the same server as apprise) LOCAL = "local" # Content is located in a remote location HOSTED = "hosted" # Content is inaccessible INACCESSIBLE = "n/a" # Define our location types so we can verify if we need to CONTENT_LOCATIONS: frozenset[str] = frozenset(e.value for e in ContentLocation) class PersistentStoreMode(str, Enum): # Allow persistent storage; write on demand AUTO = "auto" # Always flush every change to disk after it's saved. This has higher i/o # but enforces disk reflects what was set immediately FLUSH = "flush" # memory based store only MEMORY = "memory" # Define our persistent storage modes so we can verify if we need to PERSISTENT_STORE_MODES: frozenset[str] = \ frozenset(e.value for e in PersistentStoreMode) class PersistentStoreState(str, Enum): """Defines the persistent states describing what has been cached.""" # Persistent Directory is actively cross-referenced against a matching URL ACTIVE = "active" # Persistent Directory is no longer being used or has no cross-reference STALE = "stale" # Persistent Directory is not utilizing any disk space at all, however # it potentially could if the plugin it successfully cross-references # is utilized UNUSED = "unused" # Define our persistent storage states so we can verify if we need to PERSISTENT_STORE_STATES: frozenset[str] = \ frozenset(e.value for e in PersistentStoreState) # This is a reserved tag that is automatically assigned to every # Notification Plugin MATCH_ALL_TAG = "all" # Will cause notification to trigger under any circumstance even if an # exclusive tagging was provided. MATCH_ALWAYS_TAG = "always" ================================================ FILE: apprise/compat.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Added for Python 3.9 Compatibility from dataclasses import dataclass as _dataclass from typing import Any, Callable, TypeVar _T = TypeVar("_T") def dataclass_compat(*dargs: Any, **dkwargs: Any) -> Callable[[_T], _T]: """ dataclass() wrapper that drops unsupported kwargs on older Python. Python 3.9 does not support slots= in dataclasses.dataclass(). """ try: return _dataclass(*dargs, **dkwargs) except TypeError: # Only strip slots when it is the cause if "slots" in dkwargs: dkwargs.pop("slots", None) return _dataclass(*dargs, **dkwargs) raise ================================================ FILE: apprise/config/__init__.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Used for testing from ..manager_config import ConfigurationManager from .base import ConfigBase # Initalize our Config Manager Singleton C_MGR = ConfigurationManager() __all__ = [ # Reference "ConfigBase", "ConfigurationManager", ] ================================================ FILE: apprise/config/base.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from __future__ import annotations from collections import deque import os import re import time import yaml from .. import common, plugins from ..asset import AppriseAsset from ..logger import logging from ..manager_config import ConfigurationManager from ..manager_plugins import NotificationManager from ..url import URLBase from ..utils.cwe312 import cwe312_url from ..utils.parse import GET_SCHEMA_RE, parse_bool, parse_list, parse_urls from ..utils.time import zoneinfo # Test whether token is valid or not VALID_TOKEN = re.compile(r"(?P[a-z0-9][a-z0-9_]+)", re.I) # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() # Grant access to our Configuration Manager Singleton C_MGR = ConfigurationManager() class ConfigBase(URLBase): """This is the base class for all supported configuration sources.""" # The Default Encoding to use if not otherwise detected encoding = "utf-8" # The default expected configuration format unless otherwise # detected by the sub-modules default_config_format = common.ConfigFormat.TEXT # This is only set if the user overrides the config format on the URL # this should always initialize itself as None config_format = None # Don't read any more of this amount of data into memory as there is no # reason we should be reading in more. This is more of a safe guard then # anything else. 128KB (131072B) max_buffer_size = 131072 # By default all configuration is not includable using the 'include' # line found in configuration files. allow_cross_includes = common.ContentIncludeMode.NEVER # the config path manages the handling of relative include config_path = os.getcwd() def __init__( self, cache: bool | int = True, recursion: int = 0, insecure_includes: bool = False, **kwargs: object, ) -> None: """Initialize some general logging and common server arguments that will keep things consistent when working with the configurations that inherit this class. By default we cache our responses so that subsiquent calls does not cause the content to be retrieved again. For local file references this makes no difference at all. But for remote content, this does mean more then one call can be made to retrieve the (same) data. This method can be somewhat inefficient if disabled. Only disable caching if you understand the consequences. You can alternatively set the cache value to an int identifying the number of seconds the previously retrieved can exist for before it should be considered expired. recursion defines how deep we recursively handle entries that use the `include` keyword. This keyword requires us to fetch more configuration from another source and add it to our existing compilation. If the file we remotely retrieve also has an `include` reference, we will only advance through it if recursion is set to 2 deep. If set to zero it is off. There is no limit to how high you set this value. It would be recommended to keep it low if you do intend to use it. insecure_include by default are disabled. When set to True, all Apprise Config files marked to be in STRICT mode are treated as being in ALWAYS mode. Take a file:// based configuration for example, only a file:// based configuration can include another file:// based one. because it is set to STRICT mode. If an http:// based configuration file attempted to include a file:// one it woul fail. However this include would be possible if insecure_includes is set to True. There are cases where a self hosting apprise developer may wish to load configuration from memory (in a string format) that contains 'include' entries (even file:// based ones). In these circumstances if you want these 'include' entries to be honored, this value must be set to True. """ super().__init__(**kwargs) # Tracks the time the content was last retrieved on. This place a role # for cases where we are not caching our response and are required to # re-retrieve our settings. self._cached_time = None # Tracks previously loaded content for speed self._cached_servers = None # Initialize our recursion value self.recursion = recursion # Initialize our insecure_includes flag self.insecure_includes = insecure_includes if "encoding" in kwargs: # Store the encoding self.encoding = kwargs.get("encoding") fmt = kwargs.get("format") if fmt: try: self.config_format = ( fmt if isinstance(fmt, common.ConfigFormat) else common.ConfigFormat(fmt.lower()) ) except (AttributeError, ValueError): err = f"An invalid config format ({fmt}) was specified." self.logger.warning(err) raise TypeError(err) from None # Set our cache flag; it can be True or a (positive) integer try: self.cache = cache if isinstance(cache, bool) else int(cache) if self.cache < 0: err = f"A negative cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) except (ValueError, TypeError): err = f"An invalid cache value ({cache}) was specified." self.logger.warning(err) raise TypeError(err) from None return def servers( self, asset: AppriseAsset | None = None, **kwargs: object, ) -> list[plugins.NotifyBase]: """Performs reads loaded configuration and returns all of the services that could be parsed and loaded.""" if not self.expired(): # We already have cached results to return; use them return self._cached_servers # Our cached response object self._cached_servers = [] # read() causes the child class to do whatever it takes for the # config plugin to load the data source and return unparsed content # None is returned if there was an error or simply no data content = self.read(**kwargs) if not isinstance(content, str): # Set the time our content was cached at self._cached_time = time.time() # Nothing more to do; return our empty cache list return self._cached_servers # Our Configuration format uses a default if one wasn't one detected # or enfored. config_format = ( self.default_config_format if self.config_format is None else self.config_format ) # Dynamically load our parse_ function based on our config format fn = getattr(ConfigBase, f"config_parse_{config_format.value}") # Initialize our asset object asset = asset if isinstance(asset, AppriseAsset) else self.asset # Execute our config parse function which always returns a tuple # of our servers and our configuration servers, configs = fn(content=content, asset=asset) # Free memory del content # Add entry to our server list self._cached_servers.extend(servers) # Configuration files were detected; recursively populate them # If we have been configured to do so for url in configs: if self.recursion > 0: # Attempt to acquire the schema at the very least to allow # our configuration based urls. schema = GET_SCHEMA_RE.match(url) if schema is None: # Plan B is to assume we're dealing with a file schema = "file" if not os.path.isabs(url): # We're dealing with a relative path; prepend # our current config path url = os.path.join(self.config_path, url) url = f"{schema}://{URLBase.quote(url)}" else: # Ensure our schema is always in lower case schema = schema.group("schema").lower() # Some basic validation if schema not in C_MGR: ConfigBase.logger.warning( f"Unsupported include schema {schema}." ) continue # CWE-312 (Secure Logging) Handling loggable_url = ( url if not asset.secure_logging else cwe312_url(url) ) # Parse our url details of the server object as dictionary # containing all of the information parsed from our URL results = C_MGR[schema].parse_url(url) if not results: # Failed to parse the server URL self.logger.warning( f"Unparseable include URL {loggable_url}" ) continue # Handle cross inclusion based on allow_cross_includes rules if ( C_MGR[schema].allow_cross_includes == common.ContentIncludeMode.STRICT and schema not in self.schemas() and not self.insecure_includes ) or C_MGR[ schema ].allow_cross_includes == common.ContentIncludeMode.NEVER: # Prevent the loading if insecure base protocols ConfigBase.logger.warning( f"Including {schema}:// based configuration is" f" prohibited. Ignoring URL {loggable_url}" ) continue # Prepare our Asset Object results["asset"] = asset # No cache is required because we're just lumping this in # and associating it with the cache value we've already # declared (prior to our recursion) results["cache"] = False # Recursion can never be parsed from the URL; we decrement # it one level results["recursion"] = self.recursion - 1 # Insecure Includes flag can never be parsed from the URL results["insecure_includes"] = self.insecure_includes try: # Attempt to create an instance of our plugin using the # parsed URL information cfg_plugin = C_MGR[results["schema"]](**results) except Exception as e: # the arguments are invalid or can not be used. self.logger.warning( f"Could not load include URL: {loggable_url}" ) self.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we can now add this servers found # in this configuration file to our list self._cached_servers.extend(cfg_plugin.servers(asset=asset)) else: # CWE-312 (Secure Logging) Handling loggable_url = ( url if not asset.secure_logging else cwe312_url(url) ) self.logger.debug( "Recursion limit reached; ignoring Include URL: %s", loggable_url, ) if self._cached_servers: self.logger.info( f"Loaded {len(self._cached_servers)} entries from" f" {self.url(privacy=asset.secure_logging)}" ) else: self.logger.warning( "Failed to load Apprise configuration from" f" {self.url(privacy=asset.secure_logging)}" ) # Set the time our content was cached at self._cached_time = time.time() return self._cached_servers def read(self) -> str | None: """This object should be implimented by the child classes.""" return None def expired(self) -> bool: """Simply returns True if the configuration should be considered as expired or False if content should be retrieved.""" if isinstance(self._cached_servers, list) and self.cache: # We have enough reason to look further into our cached content # and verify it has not expired. if self.cache is True: # we have not expired, return False return False # Verify our cache time to determine whether we will get our # content again. age_in_sec = time.time() - self._cached_time if age_in_sec <= self.cache: # We have not expired; return False return False # If we reach here our configuration should be considered # missing and/or expired. return True @staticmethod def __normalize_tag_groups(group_tags: dict[str, set[str]]) -> None: """ Used to normalize a tag assign map which looks like: { 'group': set('{tag1}', '{group1}', '{tag2}'), 'group1': set('{tag2}','{tag3}'), } Then normalized it (merging groups); with respect to the above, the output would be: { 'group': set('{tag1}', '{tag2}', '{tag3}), 'group1': set('{tag2}','{tag3}'), } """ # Prepare a key set list we can use tag_groups = {str(x) for x in group_tags} def _expand(tags, ignore=None): """Expands based on tag provided and returns a set. this also updates the group_tags while it goes """ # Prepare ourselves a return set results = set() ignore = set() if ignore is None else ignore # track groups groups = set() for tag in tags: if tag in ignore: continue # Track our groups groups.add(tag) # Store what we know is worth keeping if tag not in group_tags: # pragma: no cover # handle cases where the tag doesn't exist group_tags[tag] = set() results |= group_tags[tag] - tag_groups # Get simple tag assignments found = group_tags[tag] & tag_groups if not found: continue for gtag in found: if gtag in ignore: continue # Go deeper (recursion) ignore.add(tag) group_tags[gtag] = _expand({gtag}, ignore=ignore) results |= group_tags[gtag] # Pop ignore ignore.remove(tag) return results for tag in tag_groups: # Get our tags group_tags[tag] |= _expand({tag}) if not group_tags[tag]: ConfigBase.logger.warning( f"The group {tag} has no tags assigned to it" ) del group_tags[tag] @staticmethod def parse_url( url: str, verify_host: bool = True, ) -> dict[str, object] | None: """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url(url, verify_host=verify_host) if not results: # We're done; we failed to parse our url return results # Allow overriding the default config format if "format" in results["qsd"]: results["format"] = results["qsd"].get("format") if results["format"] not in common.CONFIG_FORMATS: URLBase.logger.warning( "Unsupported format specified {}".format(results["format"]) ) del results["format"] # Defines the encoding of the payload if "encoding" in results["qsd"]: results["encoding"] = results["qsd"].get("encoding") # Our cache value if "cache" in results["qsd"]: # First try to get it's integer value try: results["cache"] = int(results["qsd"]["cache"]) except (ValueError, TypeError): # No problem, it just isn't an integer; now treat it as a bool # instead: results["cache"] = parse_bool(results["qsd"]["cache"]) return results @staticmethod def detect_config_format( content: str, **kwargs: object, ) -> common.ConfigFormat | None: """Takes the specified content and attempts to detect the format type. The function returns the actual format type if detected, otherwise it returns None """ # Detect Format Logic: # - A pound/hashtag (#) is alawys a comment character so we skip over # lines matched here. # - Detection begins on the first non-comment and non blank line # matched. # - If we find a string followed by a colon, we know we're dealing # with a YAML file. # - If we find a string that starts with a URL, or our tag # definitions (accepting commas) followed by an equal sign we know # we're dealing with a TEXT format. # Define what a valid line should look like valid_line_re = re.compile( r"^\s*(?P([;#]+(?P.*))|" r"(?P((?P[ \t,a-z0-9_-]+)=)?[a-z0-9]+://.*)|" r"((?P[a-z0-9]+):.*))?$", re.I, ) try: # split our content up to read line by line content = re.split(r"\r*\n", content) except TypeError: # content was not expected string type ConfigBase.logger.error("Invalid Apprise configuration specified.") return None # By default set our return value to None since we don't know # what the format is yet config_format = None # iterate over each line of the file to attempt to detect it # stop the moment a the type has been determined for line, entry in enumerate(content, start=1): result = valid_line_re.match(entry) if not result: # Invalid syntax ConfigBase.logger.error( "Undetectable Apprise configuration found " f"based on line {line}." ) # Take an early exit return None # Attempt to detect configuration if result.group("yaml"): config_format = common.ConfigFormat.YAML ConfigBase.logger.debug( f"Detected YAML configuration based on line {line}." ) break elif result.group("text"): config_format = common.ConfigFormat.TEXT ConfigBase.logger.debug( f"Detected TEXT configuration based on line {line}." ) break # If we reach here, we have a comment entry # Adjust default format to TEXT config_format = common.ConfigFormat.TEXT return config_format @staticmethod def config_parse( content: str, asset: AppriseAsset | None = None, config_format: str | common.ConfigFormat | None = None, **kwargs: object, ) -> tuple[list[object], list[str]]: """Takes the specified config content and loads it based on the specified config_format. If a format isn't specified, then it is auto detected. """ if config_format is None: # Detect the format config_format = ConfigBase.detect_config_format(content) if not config_format: # We couldn't detect configuration ConfigBase.logger.error("Could not detect configuration") return ([], []) try: config_format = ( config_format if isinstance(config_format, common.ConfigFormat) else common.ConfigFormat(config_format.lower()) ) except (AttributeError, ValueError): ConfigBase.logger.error( f"An invalid configuration format ({config_format}) was" " specified" ) return ([], []) # Dynamically load our parse_ function based on our config format fn = getattr(ConfigBase, f"config_parse_{config_format.value}") # Execute our config parse function which always returns a list return fn(content=content, asset=asset) @staticmethod def config_parse_text( content: str, asset: AppriseAsset | None = None, ) -> tuple[list[object], list[str]]: """Parse the specified content as though it were a simple text file only containing a list of URLs. Return a tuple that looks like (servers, configs) where: - servers contains a list of loaded notification plugins - configs contains a list of additional configuration files referenced. You may also optionally associate an asset with the notification. The file syntax is: # # pound/hashtag allow for line comments # # One or more tags can be idenified using comma's (,) to separate # them. = # Or you can use this format (no tags associated) # you can also use the keyword 'include' and identify a # configuration location (like this file) which will be included # as additional configuration entries when loaded. include # Assign tag contents to a group identifier = """ # A list of loaded Notification Services servers = [] # A list of additional configuration files referenced using # the include keyword configs = [] # Track all of the tags we want to assign later on group_tags = {} # Track our entries to preload preloaded = [] # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() # Define what a valid line should look like valid_line_re = re.compile( r"^\s*(?P([;#]+(?P.*))|" r"(\s*(?P[a-z0-9, \t_-]+)\s*=|=)?\s*" r"((?P[a-z0-9]{1,32}://.*)|(?P[a-z0-9, \t_-]+))|" r"include\s+(?P.+))?\s*$", re.I, ) try: # split our content up to read line by line content = re.split(r"\r*\n", content) except TypeError: # content was not expected string type ConfigBase.logger.error( "Invalid Apprise TEXT based configuration specified." ) return ([], []) for line, entry in enumerate(content, start=1): result = valid_line_re.match(entry) if not result: # Invalid syntax ConfigBase.logger.error( "Invalid Apprise TEXT configuration format found " f"{entry} on line {line}." ) # Assume this is a file we shouldn't be parsing. It's owner # can read the error printed to screen and take action # otherwise. return ([], []) # Retrieve our line url, assign, config = ( result.group("url"), result.group("assign"), result.group("config"), ) if not (url or config or assign): # Comment/empty line; do nothing continue if config: # CWE-312 (Secure Logging) Handling loggable_url = ( config if not asset.secure_logging else cwe312_url(config) ) ConfigBase.logger.debug(f"Include URL: {loggable_url}") # Store our include line configs.append(config.strip()) continue # CWE-312 (Secure Logging) Handling loggable_url = url if not asset.secure_logging else cwe312_url(url) if assign: groups = set(parse_list(result.group("tags"), cast=str)) if not groups: # no tags were assigned ConfigBase.logger.warning( "Unparseable tag assignment - no group(s) " f"on line {line}" ) continue # Get our tags tags = set(parse_list(assign, cast=str)) if not tags: # no tags were assigned ConfigBase.logger.warning( "Unparseable tag assignment - no tag(s) to assign " f"on line {line}" ) continue # Update our tag group map for tag_group in groups: if tag_group not in group_tags: group_tags[tag_group] = set() # ensure our tag group is never included in the assignment group_tags[tag_group] |= tags - {tag_group} continue # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results is None: # Failed to parse the server URL ConfigBase.logger.warning( f"Unparseable URL {loggable_url} on line {line}." ) continue # Build a list of tags to associate with the newly added # notifications if any were set results["tag"] = set(parse_list(result.group("tags"), cast=str)) # Set our Asset Object results["asset"] = asset # Store our preloaded entries preloaded.append({ "results": results, "line": line, "loggable_url": loggable_url, }) # # Normalize Tag Groups # - Expand Groups of Groups so that they don't exist # ConfigBase.__normalize_tag_groups(group_tags) # # URL Processing # for entry in preloaded: # Point to our results entry for easier reference below results = entry["results"] # # Apply our tag groups if they're defined # for group, tags in group_tags.items(): # Detect if anything assigned to this tag also maps back to a # group. If so we want to add the group to our list if next( (True for tag in results["tag"] if tag in tags), False ): results["tag"].add(group) try: # Attempt to create an instance of our plugin using the # parsed URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL ConfigBase.logger.debug( "Loaded URL: %s", plugin.url(privacy=results["asset"].secure_logging), ) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.warning( "Could not load URL {} on line {}.".format( entry["loggable_url"], entry["line"] ) ) ConfigBase.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we successfully loaded our data servers.append(plugin) # Return what was loaded return (servers, configs) @staticmethod def config_parse_yaml( content: str, asset: AppriseAsset | None = None, ) -> tuple[list[object], list[str]]: """Parse the specified content as though it were a yaml file specifically formatted for Apprise. Return a tuple that looks like (servers, configs) where: - servers contains a list of loaded notification plugins - configs contains a list of additional configuration files referenced. You may optionally associate an asset with the notification. """ # A list of loaded Notification Services servers = [] # A list of additional configuration files referenced using # the include keyword configs = [] # Group Assignments group_tags = {} # Track our entries to preload preloaded = [] try: # Load our data (safely) result = yaml.load(content, Loader=yaml.SafeLoader) except ( AttributeError, yaml.parser.ParserError, yaml.error.MarkedYAMLError, ) as e: # Invalid content ConfigBase.logger.error("Invalid Apprise YAML data specified.") ConfigBase.logger.debug(f"YAML Exception:{os.linesep}{e}") return ([], []) if not isinstance(result, dict): # Invalid content ConfigBase.logger.error( "Invalid Apprise YAML based configuration specified." ) return ([], []) # YAML Version version = result.get("version", 1) if version != 1: # Invalid syntax ConfigBase.logger.error( f"Invalid Apprise YAML version specified {version}." ) return ([], []) # # global asset object # asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() # Prepare our default timezone default_timezone = asset.tzinfo # Acquire our asset tokens tokens = result.get("asset", None) if tokens and isinstance(tokens, dict): raw_tz = tokens.get("timezone", tokens.get("tz")) if isinstance(raw_tz, str): default_timezone = zoneinfo(re.sub(r"[^\w/-]+", "", raw_tz)) if not default_timezone: ConfigBase.logger.warning( 'Ignored invalid timezone "%s"', raw_tz) default_timezone = asset.tzinfo else: asset._tzinfo = default_timezone elif raw_tz is not None: ConfigBase.logger.warning( 'Ignored invalid timezone "%r"', raw_tz) # Iterate over remaining tokens for k, v in tokens.items(): if k.startswith("_") or k.endswith("_"): # Entries are considered reserved if they start or end # with an underscore ConfigBase.logger.warning(f'Ignored asset key "{k}".') continue if not ( hasattr(asset, k) and isinstance(getattr(asset, k), (bool, str)) ): # We can't set a function or non-string set value ConfigBase.logger.warning(f'Invalid asset key "{k}".') continue if v is None: # Convert to an empty string v = "" if isinstance(v, (bool, str)) and isinstance( getattr(asset, k), bool ): # If the object in the Asset is a boolean, then # we want to convert the specified string to # match that. setattr(asset, k, parse_bool(v)) elif isinstance(v, str): # Set our asset object with the new value setattr(asset, k, v.strip()) else: # we must set strings with a string ConfigBase.logger.warning(f'Invalid asset value to "{k}".') continue # # global tag root directive # global_tags = set() tags = result.get("tag", result.get("tags", None)) if tags and isinstance(tags, (list, tuple, str)): # Store any preset tags global_tags = set(parse_list(tags, cast=str)) # # groups root directive # groups = result.get("groups", None) if isinstance(groups, dict): # # Dictionary # for groups_, tags in groups.items(): for group in parse_list(groups_, cast=str): if isinstance(tags, (list, tuple)): tags_ = set() for e in tags: if isinstance(e, dict): tags_ |= set(e.keys()) else: tags_ |= set(parse_list(e, cast=str)) # Final assignment tags = tags_ else: tags = set(parse_list(tags, cast=str)) if group not in group_tags: group_tags[group] = tags else: group_tags[group] |= tags elif isinstance(groups, (list, tuple)): # # List of Dictionaries # # Iterate over each group defined and store it for no, entry in enumerate(groups): if not isinstance(entry, dict): ConfigBase.logger.warning( f"No assignment for group {entry}, entry #{no + 1}" ) continue for groups_, tags in entry.items(): for group in parse_list(groups_, cast=str): if isinstance(tags, (list, tuple)): tags_ = set() for e in tags: if isinstance(e, dict): tags_ |= set(e.keys()) else: tags_ |= set(parse_list(e, cast=str)) # Final assignment tags = tags_ else: tags = set(parse_list(tags, cast=str)) if group not in group_tags: group_tags[group] = tags else: group_tags[group] |= tags # include root directive # includes = result.get("include", None) if isinstance(includes, str): # Support a single inline string or multiple ones separated by a # comma and/or space includes = parse_urls(includes) elif not isinstance(includes, (list, tuple)): # Not a problem; we simply have no includes includes = [] # Iterate over each config URL for _no, url in enumerate(includes): if isinstance(url, str): # Support a single inline string or multiple ones separated by # a comma and/or space configs.extend(parse_urls(url)) elif isinstance(url, dict): # Store the url and ignore arguments associated configs.extend(u for u in url) # # urls root directive # urls = result.get("urls", None) if not isinstance(urls, (list, tuple)): # Not a problem; we simply have no urls urls = [] # Iterate over each URL for no, url in enumerate(urls): # Our results object is what we use to instantiate our object if # we can. Reset it to None on each iteration results = [] # CWE-312 (Secure Logging) Handling loggable_url = url if not asset.secure_logging else cwe312_url(url) if isinstance(url, str): # We're just a simple URL string... schema = GET_SCHEMA_RE.match(url) if schema is None: # Log invalid entries so that maintainer of config # config file at least has something to take action # with. ConfigBase.logger.warning( f"Invalid URL {loggable_url}, entry #{no + 1}" ) continue # We found a valid schema worthy of tracking; store it's # details: results_ = plugins.url_to_dict( url, secure_logging=asset.secure_logging ) if results_ is None: ConfigBase.logger.warning( f"Unparseable URL {loggable_url}, entry #{no + 1}" ) continue # add our results to our global set results.append(results_) elif isinstance(url, dict): # We are a url string with additional unescaped options. In # this case we want to iterate over all of our options so we # can at least tell the end user what entries were ignored # due to errors it = iter(url.items()) # Track the URL to-load url_ = None # Track last acquired schema schema = None for key, tokens_ in it: # Test our schema schema_ = GET_SCHEMA_RE.match(key) if schema_ is None: # Log invalid entries so that maintainer of config # config file at least has something to take action # with. ConfigBase.logger.warning( f"Ignored entry {key} found under urls, entry" f" #{no + 1}" ) continue # Store our schema schema = schema_.group("schema").lower() # Store our URL and Schema Regex url_ = key # Update our token assignment tokens = tokens_ # We're done break if url_ is None: # the loop above failed to match anything ConfigBase.logger.warning( f"Unsupported URL, entry #{no + 1}" ) continue results_ = plugins.url_to_dict( url_, secure_logging=asset.secure_logging ) if results_ is None: # Setup dictionary results_ = { # Minimum requirements "schema": schema, } if isinstance(tokens, (list, tuple, set)): # populate and/or override any results populated by # parse_url() for entries in tokens: # Copy ourselves a template of our parsed URL as a base # to work with r = results_.copy() # We are a url string with additional unescaped options if isinstance(entries, dict): url_, tokens = next(iter(url.items())) # Tags you just can't over-ride if "schema" in entries: del entries["schema"] # support our special tokens (if they're present) if schema in N_MGR: entries = ConfigBase._special_token_handler( schema, entries ) # Extend our dictionary with our new entries r.update(entries) # add our results to our global set results.append(r) elif isinstance(tokens, dict): # support our special tokens (if they're present) if schema in N_MGR: tokens = ConfigBase._special_token_handler( schema, tokens ) # Copy ourselves a template of our parsed URL as a base to # work with r = results_.copy() # add our result set r.update(tokens) # add our results to our global set results.append(r) else: # add our results to our global set results.append(results_) else: # Unsupported ConfigBase.logger.warning( f"Unsupported Apprise YAML entry #{no + 1}" ) continue # Track our entries entry = 0 # Prepare our results for post-processing results = deque(results) while len(results): # Increment our entry count entry += 1 # Grab our first item results_ = results.popleft() if results_["schema"] not in N_MGR: # the arguments are invalid or can not be used. ConfigBase.logger.warning( "An invalid Apprise schema ({}) in YAML configuration " "entry #{}, item #{}".format( results_["schema"], no + 1, entry ) ) continue # tag is a special keyword that is managed by Apprise object. # The below ensures our tags are set correctly if "tag" in results_: # Tidy our list up results_["tag"] = ( set(parse_list(results_["tag"], cast=str)) | global_tags ) if "tags" in results_: ConfigBase.logger.warning(( "URL #{}: {} contains both 'tag' and 'tags' " "keyword").format(no + 1, url)) del results_["tags"] elif "tags" in results_: # Tidy our list up results_["tag"] = ( set(parse_list(results_["tags"], cast=str)) | global_tags ) # Should not carry forward del results_["tags"] else: # Just use the global settings results_["tag"] = global_tags for key in list(results_.keys()): # Strip out any tokens we know that we can't accept and # warn the user match = VALID_TOKEN.match(key) if not match: ConfigBase.logger.warning( f"Ignoring invalid token ({key}) found in YAML " f"configuration entry #{no + 1}, item #{entry}" ) del results_[key] if ConfigBase.logger.isEnabledFor(logging.TRACE): ConfigBase.logger.trace( "URL #%d: %s unpacked as:%s%s", no + 1, url, os.linesep, os.linesep.join( [f'{k}="{a}"' for k, a in results_.items()]), ) # Prepare our Asset Object results_["asset"] = asset # Handle post processing of result set results_ = URLBase.post_process_parse_url_results(results_) # Store our preloaded entries preloaded.append({ "results": results_, "entry": no + 1, "item": entry, }) # # Normalize Tag Groups # - Expand Groups of Groups so that they don't exist # ConfigBase.__normalize_tag_groups(group_tags) # # URL Processing # for entry in preloaded: # Point to our results entry for easier reference below results = entry["results"] # # Apply our tag groups if they're defined # for group, tags in group_tags.items(): # Detect if anything assigned to this tag also maps back to a # group. If so we want to add the group to our list if next( (True for tag in results["tag"] if tag in tags), False ): results["tag"].add(group) # Now we generate our plugin try: # Attempt to create an instance of our plugin using the # parsed URL information plugin = N_MGR[results["schema"]](**results) # Create log entry of loaded URL ConfigBase.logger.debug( "Loaded URL: %s", plugin.url(privacy=results["asset"].secure_logging), ) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.warning( "Could not load Apprise YAML configuration " "entry #{}, item #{}".format(entry["entry"], entry["item"]) ) ConfigBase.logger.debug(f"Loading Exception: {e!s}") continue # if we reach here, we successfully loaded our data servers.append(plugin) preloaded.clear() return (servers, configs) def pop(self, index: int = -1) -> object: """Removes an indexed Notification Service from the stack and returns it. By default, the last element of the list is removed. """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() # Pop the element off of the stack return self._cached_servers.pop(index) def clear_cache(self) -> None: """Cleans cache""" self._cached_servers = None self._cached_time = None @staticmethod def _special_token_handler( schema: str, tokens: dict[str, object], ) -> dict[str, object]: """This function takes a list of tokens and updates them to no longer include any special tokens such as +,-, and : - schema must be a valid schema of a supported plugin type - tokens must be a dictionary containing the yaml entries parsed. The idea here is we can post process a set of tokens provided in a YAML file where the user provided some of the special keywords. We effectivley look up what these keywords map to their appropriate value they're expected """ # Create a copy of our dictionary tokens = tokens.copy() for kw, meta in N_MGR[schema].template_kwargs.items(): # Determine our prefix: prefix = meta.get("prefix", "+") # Detect any matches matches = { k[1:]: str(v) for k, v in tokens.items() if k.startswith(prefix) } if not matches: # we're done with this entry continue if not isinstance(tokens.get(kw), dict): # Invalid; correct it tokens[kw] = {} # strip out processed tokens tokens = { k: v for k, v in tokens.items() if not k.startswith(prefix) } # Update our entries tokens[kw].update(matches) # Now map our tokens accordingly to the class templates defined by # each service. # # This is specifically used for YAML file parsing. It allows a user to # define an entry such as: # # urls: # - mailto://user:pass@domain: # - to: user1@hotmail.com # - to: user2@hotmail.com # # Under the hood, the NotifyEmail() class does not parse the `to` # argument. It's contents needs to be mapped to `targets`. This is # defined in the class via the `template_args` and template_tokens` # section. # # This function here allows these mappings to take place within the # YAML file as independant arguments. class_templates = plugins.details(N_MGR[schema]) for key in list(tokens.keys()): if key not in class_templates["args"]: # No need to handle non-arg entries continue # get our `map_to` and/or 'alias_of' value (if it exists) map_to = class_templates["args"][key].get( "alias_of", class_templates["args"][key].get("map_to", "") ) if map_to == key: # We're already good as we are now continue if map_to in class_templates["tokens"]: meta = class_templates["tokens"][map_to] else: meta = class_templates["args"].get( map_to, class_templates["args"][key] ) # Perform a translation/mapping if our code reaches here value = tokens[key] del tokens[key] # Detect if we're dealign with a list or not is_list = re.search(r"^list:.*", meta.get("type"), re.IGNORECASE) if map_to not in tokens: tokens[map_to] = [] if is_list else meta.get("default") elif is_list and not isinstance(tokens.get(map_to), list): # Convert ourselves to a list if we aren't already tokens[map_to] = [tokens[map_to]] # Type Conversion if re.search( r"^(choice:)?string", meta.get("type"), re.IGNORECASE ) and not isinstance(value, str): # Ensure our format is as expected value = str(value) # Apply any further translations if required (absolute map) # This is the case when an arg maps to a token which further # maps to a different function arg on the class constructor abs_map = meta.get("map_to", map_to) # Set our token as how it was provided by the configuration if isinstance(tokens.get(map_to), list): tokens[abs_map].append(value) else: tokens[abs_map] = value # Return our tokens return tokens def __getitem__(self, index: int) -> object: """Returns the indexed server entry associated with the loaded notification servers.""" if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return self._cached_servers[index] def __iter__(self) -> object: """Returns an iterator to our server list.""" if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return iter(self._cached_servers) def __len__(self) -> int: """Returns the total number of servers loaded.""" if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return len(self._cached_servers) def __bool__(self) -> bool: """Allows the Apprise object to be wrapped in an 'if statement'. True is returned if our content was downloaded correctly. """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from self.servers() return bool(self._cached_servers) ================================================ FILE: apprise/config/file.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import os import re from ..common import ConfigFormat, ContentIncludeMode from ..locale import gettext_lazy as _ from ..utils.disk import path_decode from .base import ConfigBase class ConfigFile(ConfigBase): """A wrapper for File based configuration sources.""" # The default descriptive name associated with the service service_name = _("Local File") # The default protocol protocol = "file" # Configuration file inclusion can only be of the same type allow_cross_includes = ContentIncludeMode.STRICT def __init__(self, path, **kwargs): """Initialize File Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) # Store our file path as it was set self.path = path_decode(path) # Track the file as it was saved self.__original_path = os.path.normpath(path) # Update the config path to be relative to our file we just loaded self.config_path = os.path.dirname(self.path) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our cache value if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Define any URL parameters params = { "encoding": self.encoding, "cache": cache, } if self.config_format: # A format was enforced; make sure it's passed back with the url params["format"] = self.config_format return "file://{path}{params}".format( path=self.quote(self.__original_path), params=f"?{self.urlencode(params)}" if params else "", ) def read(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" response = None try: if ( self.max_buffer_size > 0 and os.path.getsize(self.path) > self.max_buffer_size ): # Content exceeds maximum buffer size self.logger.error( "File size exceeds maximum allowable buffer length" f" ({int(self.max_buffer_size / 1024)}KB)." ) return None except OSError: # getsize() can throw this acception if the file is missing # and or simply isn't accessible self.logger.error(f"File is not accessible: {self.path}") return None # Always call throttle before any server i/o is made self.throttle() try: with open(self.path, encoding=self.encoding) as f: # Store our content for parsing response = f.read() except (ValueError, UnicodeDecodeError): # A result of our strict encoding check; if we receive this # then the file we're opening is not something we can # understand the encoding of.. self.logger.error( f"File not using expected encoding ({self.encoding}) :" f" {self.path}" ) return None except OSError: # IOError is present for backwards compatibility with Python # versions older then 3.3. >= 3.3 throw OSError now. # Could not open and/or read the file; this is not a problem since # we scan a lot of default paths. self.logger.error(f"File can not be opened for read: {self.path}") return None # Detect config format based on file extension if it isn't already # enforced if ( self.config_format is None and re.match(r"^.*\.ya?ml\s*$", self.path, re.I) is not None ): # YAML Filename Detected self.default_config_format = ConfigFormat.YAML # Return our response object return response @staticmethod def parse_url(url): """Parses the URL so that we can handle all different file paths and return it as our path object.""" results = ConfigBase.parse_url(url, verify_host=False) if not results: # We're done early; it's not a good URL return results match = re.match(r"[a-z0-9]+://(?P[^?]+)(\?.*)?", url, re.I) if not match: return None results["path"] = ConfigFile.unquote(match.group("path")) return results ================================================ FILE: apprise/config/http.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import requests from ..common import ConfigFormat, ContentIncludeMode from ..locale import gettext_lazy as _ from ..url import PrivacyMode from .base import ConfigBase # Support YAML formats # text/yaml # text/x-yaml # application/yaml # application/x-yaml MIME_IS_YAML = re.compile(r"(text|application)/(x-)?yaml", re.I) # Support TEXT formats # text/plain # text/html MIME_IS_TEXT = re.compile(r"text/(plain|html)", re.I) class ConfigHTTP(ConfigBase): """A wrapper for HTTP based configuration sources.""" # The default descriptive name associated with the service service_name = _("Web Based") # The default protocol protocol = "http" # The default secure protocol secure_protocol = "https" # If an HTTP error occurs, define the number of characters you still want # to read back. This is useful for debugging purposes, but nothing else. # The idea behind enforcing this kind of restriction is to prevent abuse # from queries to services that may be untrusted. max_error_buffer_size = 2048 # Configuration file inclusion can always include this type allow_cross_includes = ContentIncludeMode.ALWAYS def __init__(self, headers=None, **kwargs): """Initialize HTTP Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.schema = "https" if self.secure else "http" self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "/" self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our cache value if isinstance(self.cache, bool) or not self.cache: cache = "yes" if self.cache else "no" else: cache = int(self.cache) # Define any arguments set params = { "encoding": self.encoding, "cache": cache, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) if self.config_format: # A format was enforced; make sure it's passed back with the url params["format"] = self.config_format # Append our headers into our args params.update({f"+{k}": v for k, v in self.headers.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=self.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=self.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, hostname=self.quote(self.host, safe=""), port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=self.quote(self.fullpath, safe="/"), params=self.urlencode(params), ) def read(self, **kwargs): """Perform retrieval of the configuration based on the specified request.""" # prepare XML Object headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) auth = None if self.user: auth = (self.user, self.password) url = f"{self.schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath self.logger.debug( f"HTTP POST URL: {url} (cert_verify={self.verify_certificate!r})" ) # Prepare our response object response = None # Where our request object will temporarily live. r = None # Always call throttle before any remote server i/o is made self.throttle() try: # Make our request with requests.post( url, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, stream=True, ) as r: # Handle Errors r.raise_for_status() # Get our file-size (if known) try: file_size = int(r.headers.get("Content-Length", "0")) except (TypeError, ValueError): # Handle edge case where Content-Length is a bad value file_size = 0 # Store our response if ( self.max_buffer_size > 0 and file_size > self.max_buffer_size ): # Provide warning of data truncation self.logger.error( "HTTP config response exceeds maximum buffer length " f"({int(self.max_buffer_size / 1024)}KB);" ) # Return None - buffer execeeded return None # Store our result (but no more than our buffer length) response = r.text[: self.max_buffer_size + 1] # Verify that our content did not exceed the buffer size: if len(response) > self.max_buffer_size: # Provide warning of data truncation self.logger.error( "HTTP config response exceeds maximum buffer length " f"({int(self.max_buffer_size / 1024)}KB);" ) # Return None - buffer execeeded return None # Detect config format based on mime if the format isn't # already enforced content_type = r.headers.get( "Content-Type", "application/octet-stream" ) if self.config_format is None and content_type: if MIME_IS_YAML.match(content_type) is not None: # YAML data detected based on header content self.default_config_format = ConfigFormat.YAML elif MIME_IS_TEXT.match(content_type) is not None: # TEXT data detected based on header content self.default_config_format = ConfigFormat.TEXT except requests.RequestException as e: self.logger.error( "A Connection error occurred retrieving HTTP " f"configuration from {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return None (signifying a failure) return None # Return our response object return response @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = ConfigBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set results["headers"] = results["qsd-"] results["headers"].update(results["qsd+"]) return results ================================================ FILE: apprise/config/memory.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ..locale import gettext_lazy as _ from .base import ConfigBase class ConfigMemory(ConfigBase): """For information that was loaded from memory and does not persist anywhere.""" # The default descriptive name associated with the service service_name = _("Memory") # The default protocol protocol = "memory" def __init__(self, content, **kwargs): """Initialize Memory Object. Memory objects just store the raw configuration in memory. There is no external reference point. It's always considered cached. """ super().__init__(**kwargs) # Store our raw config into memory self.content = content if self.config_format is None: # Detect our format if possible self.config_format = ConfigMemory.detect_config_format( self.content ) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" return "memory://" def read(self, **kwargs): """Simply return content stored into memory.""" return self.content @staticmethod def parse_url(url): """Memory objects have no parseable URL.""" # These URLs can not be parsed return None ================================================ FILE: apprise/conversion.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from html.parser import HTMLParser import re from markdown import markdown from .common import NotifyFormat from .url import URLBase def convert_between(from_format, to_format, content): """Converts between different suported formats. If no conversion exists, or the selected one fails, the original text will be returned. This function returns the content translated (if required) """ converters = { (NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html, (NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html, (NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text, # For now; use same converter for Markdown support (NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text, } convert = converters.get((from_format, to_format)) return convert(content) if convert else content def markdown_to_html(content): """Converts specified content from markdown to HTML.""" return markdown( content, extensions=["markdown.extensions.nl2br", "markdown.extensions.tables"], ) def text_to_html(content): """Converts specified content from plain text to HTML.""" # First eliminate any carriage returns return URLBase.escape_html(content, convert_new_lines=True) def html_to_text(content): """Converts a content from HTML to plain text.""" parser = HTMLConverter() parser.feed(content) parser.close() return parser.converted class HTMLConverter(HTMLParser): """An HTML to plain text converter tuned for email messages.""" # The following tags must start on a new line BLOCK_TAGS = ( "p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "td", "th", "code", "pre", "label", "li", ) # the folowing tags ignore any internal text IGNORE_TAGS = ( "form", "input", "textarea", "select", "ul", "ol", "style", "link", "meta", "title", "html", "head", "script", ) # Condense Whitespace WS_TRIM = re.compile(r"[\s]+", re.DOTALL | re.MULTILINE) # Sentinel value for block tag boundaries, which may be consolidated into a # single line break. BLOCK_END = {} def __init__(self, **kwargs): super().__init__(**kwargs) # Shoudl we store the text content or not? self._do_store = True # Initialize internal result list self._result = [] # Initialize public result field (not populated until close() is # called) self.converted = "" def close(self): string = "".join(self._finalize(self._result)) self.converted = string.strip() def _finalize(self, result): """Combines and strips consecutive strings, then converts consecutive block ends into singleton newlines. [ {be} " Hello " {be} {be} " World!" ] -> "\nHello\nWorld!" """ # None means the last visited item was a block end. accum = None for item in result: if item == self.BLOCK_END: # Multiple consecutive block ends; do nothing. if accum is None: continue # First block end; yield the current string, plus a newline. yield accum.strip() + "\n" accum = None # Multiple consecutive strings; combine them. elif accum is not None: accum += item # First consecutive string; store it. else: accum = item # Yield the last string if we have not already done so. if accum is not None: yield accum.strip() def handle_data(self, data, *args, **kwargs): """Store our data if it is not on the ignore list.""" # initialize our previous flag if self._do_store: # Tidy our whitespace content = self.WS_TRIM.sub(" ", data) self._result.append(content) def handle_starttag(self, tag, attrs): """Process our starting HTML Tag.""" # Toggle initial states self._do_store = tag not in self.IGNORE_TAGS if tag in self.BLOCK_TAGS: self._result.append(self.BLOCK_END) if tag == "li": self._result.append("- ") elif tag == "br": self._result.append("\n") elif tag == "hr": if self._result and isinstance(self._result[-1], str): self._result[-1] = self._result[-1].rstrip(" ") self._result.append("\n---\n") elif tag == "blockquote": self._result.append(" >") def handle_endtag(self, tag): """Edge case handling of open/close tags.""" self._do_store = True if tag in self.BLOCK_TAGS: self._result.append(self.BLOCK_END) ================================================ FILE: apprise/decorators/__init__.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .notify import notify __all__ = ["notify"] ================================================ FILE: apprise/decorators/base.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import inspect from .. import common from ..logger import logger from ..manager_plugins import NotificationManager from ..plugins.base import NotifyBase from ..utils.logic import dict_full_update from ..utils.parse import URL_DETAILS_RE, parse_url, url_assembly # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() class CustomNotifyPlugin(NotifyBase): """Apprise Custom Plugin Hook. This gets initialized based on @notify decorator definitions """ # Our Custom notification; identify the URL users can go to learn # more about the service this wrapper supports: service_url = "https://appriseit.com/library/extending/decorator/" # Over-ride our category since this inheritance of the NotifyBase class # should be treated differently. category = "custom" # Support Attachments attachment_support = True # Allow persistent storage support storage_mode = common.PersistentStoreMode.AUTO # Define object templates templates = ("{schema}://",) @staticmethod def parse_url(url): """Parses the URL and returns arguments retrieved.""" return parse_url(url, verify_host=False, simple=True) def url(self, privacy=False, *args, **kwargs): """General URL assembly.""" return f"{self.secure_protocol}://" @staticmethod def instantiate_plugin(url, send_func, name=None): """The function used to add a new notification plugin based on the schema parsed from the provided URL into our supported matrix structure.""" if not isinstance(url, str): msg = ( f"An invalid custom notify url/schema ({url}) provided in " f"function {send_func.__name__}." ) logger.warning(msg) return None # Validate that our schema is okay re_match = URL_DETAILS_RE.match(url) if not re_match: msg = ( f"An invalid custom notify url/schema ({url}) provided in " f"function {send_func.__name__}." ) logger.warning(msg) return None # Acquire our schema schema = re_match.group("schema").lower() if not re_match.group("base"): url = f"{schema}://" # Keep a default set of arguments to apply to all called references base_args = parse_url( url, default_schema=schema, verify_host=False, simple=True ) if schema in N_MGR: # we're already handling this object msg = ( f"The schema ({url}) is already defined and could not be " f"loaded from custom notify function {send_func.__name__}." ) logger.warning(msg) return None # We define our own custom wrapper class so that we can initialize # some key default configuration values allowing calls to our # `Apprise.details()` to correctly differentiate one custom plugin # that was loaded from another class CustomNotifyPluginWrapper(CustomNotifyPlugin): # Our Service Name service_name = ( name if isinstance(name, str) and name else f"Custom - {schema}" ) # Store our matched schema secure_protocol = schema requirements = { # Define our required packaging in order to work "details": f"Source: {inspect.getfile(send_func)}" } # Assign our send() function __send = staticmethod(send_func) # Update our default arguments _base_args = base_args def __init__(self, **kwargs): """Our initialization.""" # init parent super().__init__(**kwargs) self._default_args = {} # Some variables do not need to be set kwargs.pop("secure", None) # Apply our updates based on what was parsed dict_full_update(self._default_args, self._base_args) dict_full_update(self._default_args, kwargs) # Update our arguments (applying them to what we originally) # initialized as self._default_args["url"] = url_assembly(**self._default_args) def send( self, body, title="", notify_type=common.NotifyType.INFO, *args, **kwargs, ): """Our send() call which triggers our hook.""" response = False try: # Enforce a boolean response result = self.__send( body, title, notify_type.value, *args, meta=self._default_args, **kwargs, ) # None and True are both considered successful # False however is passed along further upstream response = True if result is None else bool(result) except Exception as e: # Unhandled Exception self.logger.warning( "An exception occured sending a %s notification.", N_MGR[self.secure_protocol].service_name, ) self.logger.debug( "%s Exception: %s", N_MGR[self.secure_protocol], e) return False if response: self.logger.info( "Sent %s notification.", N_MGR[self.secure_protocol].service_name, ) else: self.logger.warning( "Failed to send %s notification.", N_MGR[self.secure_protocol].service_name, ) return response # Store our plugin into our core map file return N_MGR.add( plugin=CustomNotifyPluginWrapper, schemas=schema, send_func=send_func, url=url, ) ================================================ FILE: apprise/decorators/notify.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from .base import CustomNotifyPlugin def notify(on, name=None): """ @notify decorator allows you to map functions you've defined to be loaded as a regular notify by Apprise. You must identify a protocol that users will trigger your call by. @notify(on="foobar") def your_declaration(body, title, notify_type, meta, *args, **kwargs): ... You can optionally provide the name to associate with the plugin which is what calling functions via the API will receive. @notify(on="foobar", name="My Foobar Process") def your_action(body, title, notify_type, meta, *args, **kwargs): ... The meta variable is actually the processed URL contents found in configuration files that landed you in this function you wrote in the first place. It's very easily tokenized already for you so that you can bend the notification logic to your hearts content. @notify(on="foobar", name="My Foobar Process") def your_action(body, title, notify_type, body_format, meta, attach, *args, **kwargs): ... Arguments break down as follows: body: The message body associated with the notification title: The message title associated with the notification notify_type: The message type (info, success, warning, and failure) body_format: The format of the incoming notification body. This is either text, html, or markdown. meta: Combines the URL arguments specified on the `on` call with the ones loaded from a users configuration. This is a dictionary that presents itself like this: { 'schema': 'http', 'url': 'http://hostname', 'host': 'hostname', 'user': 'john', 'password': 'doe', 'port': 80, 'path': '/', 'fullpath': '/test.php', 'query': 'test.php', 'qsd': {'key': 'value', 'key2': 'value2'}, 'asset': , 'tag': set(), } Meta entries are ONLY present if found. A simple URL such as foobar:// would only produce the following: { 'schema': 'foobar', 'url': 'foobar://', 'asset': , 'tag': set(), } attach: An array AppriseAttachment objects (if any were provided) body_format: Defaults to the expected format output; By default this will be TEXT unless over-ridden in the Apprise URL If you don't intend on using all of the parameters, your @notify() call # can be greatly simplified to just: @notify(on="foobar", name="My Foobar Process") def your_action(body, title, *args, **kwargs) Always end your wrappers declaration with *args and **kwargs to be future proof with newer versions of Apprise. Your wrapper should return True if processed the send() function as you expected and return False if not. If nothing is returned, then this is treated as as success (True). """ def wrapper(func): """Instantiate our custom (notification) plugin.""" # Generate CustomNotifyPlugin.instantiate_plugin( url=on, send_func=func, name=name ) return func return wrapper ================================================ FILE: apprise/emojis.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import time from .logger import logger # All Emoji's are wrapped in this character DELIM = ":" # the map simply contains the emoji that should be mapped to the regular # expression it should be swapped on. # This list was based on: https://github.com/ikatyang/emoji-cheat-sheet EMOJI_MAP = { # # Face Smiling # DELIM + r"grinning" + DELIM: "😄", DELIM + r"smile" + DELIM: "😄", DELIM + r"(laughing|satisfied)" + DELIM: "😆", DELIM + r"rofl" + DELIM: "🤣", DELIM + r"slightly_smiling_face" + DELIM: "🙂", DELIM + r"wink" + DELIM: "😉", DELIM + r"innocent" + DELIM: "😇", DELIM + r"smiley" + DELIM: "😃", DELIM + r"grin" + DELIM: "😃", DELIM + r"sweat_smile" + DELIM: "😅", DELIM + r"joy" + DELIM: "😂", DELIM + r"upside_down_face" + DELIM: "🙃", DELIM + r"blush" + DELIM: "😊", # # Face Affection # DELIM + r"smiling_face_with_three_hearts" + DELIM: "🥰", DELIM + r"star_struck" + DELIM: "🤩", DELIM + r"kissing" + DELIM: "😗", DELIM + r"kissing_closed_eyes" + DELIM: "😚", DELIM + r"smiling_face_with_tear" + DELIM: "🥲", DELIM + r"heart_eyes" + DELIM: "😍", DELIM + r"kissing_heart" + DELIM: "😘", DELIM + r"relaxed" + DELIM: "☺️", DELIM + r"kissing_smiling_eyes" + DELIM: "😙", # # Face Tongue # DELIM + r"yum" + DELIM: "😋", DELIM + r"stuck_out_tongue_winking_eye" + DELIM: "😜", DELIM + r"stuck_out_tongue_closed_eyes" + DELIM: "😝", DELIM + r"stuck_out_tongue" + DELIM: "😛", DELIM + r"zany_face" + DELIM: "🤪", DELIM + r"money_mouth_face" + DELIM: "🤑", # # Face Hand # DELIM + r"hugs" + DELIM: "🤗", DELIM + r"shushing_face" + DELIM: "🤫", DELIM + r"hand_over_mouth" + DELIM: "🤭", DELIM + r"thinking" + DELIM: "🤔", # # Face Neutral Skeptical # DELIM + r"zipper_mouth_face" + DELIM: "🤐", DELIM + r"neutral_face" + DELIM: "😐", DELIM + r"no_mouth" + DELIM: "😶", DELIM + r"smirk" + DELIM: "😏", DELIM + r"roll_eyes" + DELIM: "🙄", DELIM + r"face_exhaling" + DELIM: "😮‍💨", DELIM + r"raised_eyebrow" + DELIM: "🤨", DELIM + r"expressionless" + DELIM: "😑", DELIM + r"face_in_clouds" + DELIM: "😶‍🌫️", DELIM + r"unamused" + DELIM: "😒", DELIM + r"grimacing" + DELIM: "😬", DELIM + r"lying_face" + DELIM: "🤥", # # Face Sleepy # DELIM + r"relieved" + DELIM: "😌", DELIM + r"sleepy" + DELIM: "😪", DELIM + r"sleeping" + DELIM: "😴", DELIM + r"pensive" + DELIM: "😔", DELIM + r"drooling_face" + DELIM: "🤤", # # Face Unwell # DELIM + r"mask" + DELIM: "😷", DELIM + r"face_with_head_bandage" + DELIM: "🤕", DELIM + r"vomiting_face" + DELIM: "🤮", DELIM + r"hot_face" + DELIM: "🥵", DELIM + r"woozy_face" + DELIM: "🥴", DELIM + r"face_with_spiral_eyes" + DELIM: "😵‍💫", DELIM + r"face_with_thermometer" + DELIM: "🤒", DELIM + r"nauseated_face" + DELIM: "🤢", DELIM + r"sneezing_face" + DELIM: "🤧", DELIM + r"cold_face" + DELIM: "🥶", DELIM + r"dizzy_face" + DELIM: "😵", DELIM + r"exploding_head" + DELIM: "🤯", # # Face Hat # DELIM + r"cowboy_hat_face" + DELIM: "🤠", DELIM + r"disguised_face" + DELIM: "🥸", DELIM + r"partying_face" + DELIM: "🥳", # # Face Glasses # DELIM + r"sunglasses" + DELIM: "😎", DELIM + r"monocle_face" + DELIM: "🧐", DELIM + r"nerd_face" + DELIM: "🤓", # # Face Concerned # DELIM + r"confused" + DELIM: "😕", DELIM + r"slightly_frowning_face" + DELIM: "🙁", DELIM + r"open_mouth" + DELIM: "😮", DELIM + r"astonished" + DELIM: "😲", DELIM + r"pleading_face" + DELIM: "🥺", DELIM + r"anguished" + DELIM: "😧", DELIM + r"cold_sweat" + DELIM: "😰", DELIM + r"cry" + DELIM: "😢", DELIM + r"scream" + DELIM: "😱", DELIM + r"persevere" + DELIM: "😣", DELIM + r"sweat" + DELIM: "😓", DELIM + r"tired_face" + DELIM: "😫", DELIM + r"worried" + DELIM: "😟", DELIM + r"frowning_face" + DELIM: "☹️", DELIM + r"hushed" + DELIM: "😯", DELIM + r"flushed" + DELIM: "😳", DELIM + r"frowning" + DELIM: "😦", DELIM + r"fearful" + DELIM: "😨", DELIM + r"disappointed_relieved" + DELIM: "😥", DELIM + r"sob" + DELIM: "😭", DELIM + r"confounded" + DELIM: "😖", DELIM + r"disappointed" + DELIM: "😞", DELIM + r"weary" + DELIM: "😩", DELIM + r"yawning_face" + DELIM: "🥱", # # Face Negative # DELIM + r"triumph" + DELIM: "😤", DELIM + r"angry" + DELIM: "😠", DELIM + r"smiling_imp" + DELIM: "😈", DELIM + r"skull" + DELIM: "💀", DELIM + r"(pout|rage)" + DELIM: "😡", DELIM + r"cursing_face" + DELIM: "🤬", DELIM + r"imp" + DELIM: "👿", DELIM + r"skull_and_crossbones" + DELIM: "☠️", # # Face Costume # DELIM + r"(hankey|poop|shit)" + DELIM: "💩", DELIM + r"japanese_ogre" + DELIM: "👹", DELIM + r"ghost" + DELIM: "👻", DELIM + r"space_invader" + DELIM: "👾", DELIM + r"clown_face" + DELIM: "🤡", DELIM + r"japanese_goblin" + DELIM: "👺", DELIM + r"alien" + DELIM: "👽", DELIM + r"robot" + DELIM: "🤖", # # Cat Face # DELIM + r"smiley_cat" + DELIM: "😺", DELIM + r"joy_cat" + DELIM: "😹", DELIM + r"smirk_cat" + DELIM: "😼", DELIM + r"scream_cat" + DELIM: "🙀", DELIM + r"pouting_cat" + DELIM: "😾", DELIM + r"smile_cat" + DELIM: "😸", DELIM + r"heart_eyes_cat" + DELIM: "😻", DELIM + r"kissing_cat" + DELIM: "😽", DELIM + r"crying_cat_face" + DELIM: "😿", # # Monkey Face # DELIM + r"see_no_evil" + DELIM: "🙈", DELIM + r"speak_no_evil" + DELIM: "🙊", DELIM + r"hear_no_evil" + DELIM: "🙉", # # Heart # DELIM + r"love_letter" + DELIM: "💌", DELIM + r"gift_heart" + DELIM: "💝", DELIM + r"heartpulse" + DELIM: "💗", DELIM + r"revolving_hearts" + DELIM: "💞", DELIM + r"heart_decoration" + DELIM: "💟", DELIM + r"broken_heart" + DELIM: "💔", DELIM + r"mending_heart" + DELIM: "❤️‍🩹", DELIM + r"orange_heart" + DELIM: "🧡", DELIM + r"green_heart" + DELIM: "💚", DELIM + r"purple_heart" + DELIM: "💜", DELIM + r"black_heart" + DELIM: "🖤", DELIM + r"cupid" + DELIM: "💘", DELIM + r"sparkling_heart" + DELIM: "💖", DELIM + r"heartbeat" + DELIM: "💓", DELIM + r"two_hearts" + DELIM: "💕", DELIM + r"heavy_heart_exclamation" + DELIM: "❣️", DELIM + r"heart_on_fire" + DELIM: "❤️‍🔥", DELIM + r"heart" + DELIM: "❤️", DELIM + r"yellow_heart" + DELIM: "💛", DELIM + r"blue_heart" + DELIM: "💙", DELIM + r"brown_heart" + DELIM: "🤎", DELIM + r"white_heart" + DELIM: "🤍", # # Emotion # DELIM + r"kiss" + DELIM: "💋", DELIM + r"anger" + DELIM: "💢", DELIM + r"dizzy" + DELIM: "💫", DELIM + r"dash" + DELIM: "💨", DELIM + r"speech_balloon" + DELIM: "💬", DELIM + r"left_speech_bubble" + DELIM: "🗨️", DELIM + r"thought_balloon" + DELIM: "💭", DELIM + r"100" + DELIM: "💯", DELIM + r"(boom|collision)" + DELIM: "💥", DELIM + r"sweat_drops" + DELIM: "💦", DELIM + r"hole" + DELIM: "🕳️", DELIM + r"eye_speech_bubble" + DELIM: "👁️‍🗨️", DELIM + r"right_anger_bubble" + DELIM: "🗯️", DELIM + r"zzz" + DELIM: "💤", # # Hand Fingers Open # DELIM + r"wave" + DELIM: "👋", DELIM + r"raised_hand_with_fingers_splayed" + DELIM: "🖐️", DELIM + r"vulcan_salute" + DELIM: "🖖", DELIM + r"raised_back_of_hand" + DELIM: "🤚", DELIM + r"(raised_)?hand" + DELIM: "✋", # # Hand Fingers Partial # DELIM + r"ok_hand" + DELIM: "👌", DELIM + r"pinched_fingers" + DELIM: "🤌", DELIM + r"pinching_hand" + DELIM: "🤏", DELIM + r"v" + DELIM: "✌️", DELIM + r"crossed_fingers" + DELIM: "🤞", DELIM + r"love_you_gesture" + DELIM: "🤟", DELIM + r"metal" + DELIM: "🤘", DELIM + r"call_me_hand" + DELIM: "🤙", # # Hand Single Finger # DELIM + r"point_left" + DELIM: "👈", DELIM + r"point_right" + DELIM: "👉", DELIM + r"point_up_2" + DELIM: "👆", DELIM + r"(fu|middle_finger)" + DELIM: "🖕", DELIM + r"point_down" + DELIM: "👇", DELIM + r"point_up" + DELIM: "☝️", # # Hand Fingers Closed # DELIM + r"(\+1|thumbsup)" + DELIM: "👍", DELIM + r"(-1|thumbsdown)" + DELIM: "👎", DELIM + r"fist" + DELIM: "✊", DELIM + r"(fist_(raised|oncoming)|(face)?punch)" + DELIM: "👊", DELIM + r"fist_left" + DELIM: "🤛", DELIM + r"fist_right" + DELIM: "🤜", # # Hands # DELIM + r"clap" + DELIM: "👏", DELIM + r"raised_hands" + DELIM: "🙌", DELIM + r"open_hands" + DELIM: "👐", DELIM + r"palms_up_together" + DELIM: "🤲", DELIM + r"handshake" + DELIM: "🤝", DELIM + r"pray" + DELIM: "🙏", # # Hand Prop # DELIM + r"writing_hand" + DELIM: "✍️", DELIM + r"nail_care" + DELIM: "💅", DELIM + r"selfie" + DELIM: "🤳", # # Body Parts # DELIM + r"muscle" + DELIM: "💪", DELIM + r"mechanical_arm" + DELIM: "🦾", DELIM + r"mechanical_leg" + DELIM: "🦿", DELIM + r"leg" + DELIM: "🦵", DELIM + r"foot" + DELIM: "🦶", DELIM + r"ear" + DELIM: "👂", DELIM + r"ear_with_hearing_aid" + DELIM: "🦻", DELIM + r"nose" + DELIM: "👃", DELIM + r"brain" + DELIM: "🧠", DELIM + r"anatomical_heart" + DELIM: "🫀", DELIM + r"lungs" + DELIM: "🫁", DELIM + r"tooth" + DELIM: "🦷", DELIM + r"bone" + DELIM: "🦴", DELIM + r"eyes" + DELIM: "👀", DELIM + r"eye" + DELIM: "👁️", DELIM + r"tongue" + DELIM: "👅", DELIM + r"lips" + DELIM: "👄", # # Person # DELIM + r"baby" + DELIM: "👶", DELIM + r"child" + DELIM: "🧒", DELIM + r"boy" + DELIM: "👦", DELIM + r"girl" + DELIM: "👧", DELIM + r"adult" + DELIM: "🧑", DELIM + r"blond_haired_person" + DELIM: "👱", DELIM + r"man" + DELIM: "👨", DELIM + r"bearded_person" + DELIM: "🧔", DELIM + r"man_beard" + DELIM: "🧔‍♂️", DELIM + r"woman_beard" + DELIM: "🧔‍♀️", DELIM + r"red_haired_man" + DELIM: "👨‍🦰", DELIM + r"curly_haired_man" + DELIM: "👨‍🦱", DELIM + r"white_haired_man" + DELIM: "👨‍🦳", DELIM + r"bald_man" + DELIM: "👨‍🦲", DELIM + r"woman" + DELIM: "👩", DELIM + r"red_haired_woman" + DELIM: "👩‍🦰", DELIM + r"person_red_hair" + DELIM: "🧑‍🦰", DELIM + r"curly_haired_woman" + DELIM: "👩‍🦱", DELIM + r"person_curly_hair" + DELIM: "🧑‍🦱", DELIM + r"white_haired_woman" + DELIM: "👩‍🦳", DELIM + r"person_white_hair" + DELIM: "🧑‍🦳", DELIM + r"bald_woman" + DELIM: "👩‍🦲", DELIM + r"person_bald" + DELIM: "🧑‍🦲", DELIM + r"blond_(haired_)?woman" + DELIM: "👱‍♀️", DELIM + r"blond_haired_man" + DELIM: "👱‍♂️", DELIM + r"older_adult" + DELIM: "🧓", DELIM + r"older_man" + DELIM: "👴", DELIM + r"older_woman" + DELIM: "👵", # # Person Gesture # DELIM + r"frowning_person" + DELIM: "🙍", DELIM + r"frowning_man" + DELIM: "🙍‍♂️", DELIM + r"frowning_woman" + DELIM: "🙍‍♀️", DELIM + r"pouting_face" + DELIM: "🙎", DELIM + r"pouting_man" + DELIM: "🙎‍♂️", DELIM + r"pouting_woman" + DELIM: "🙎‍♀️", DELIM + r"no_good" + DELIM: "🙅", DELIM + r"(ng|no_good)_man" + DELIM: "🙅‍♂️", DELIM + r"(ng_woman|no_good_woman)" + DELIM: "🙅‍♀️", DELIM + r"ok_person" + DELIM: "🙆", DELIM + r"ok_man" + DELIM: "🙆‍♂️", DELIM + r"ok_woman" + DELIM: "🙆‍♀️", DELIM + r"(information_desk|tipping_hand_)person" + DELIM: "💁", DELIM + r"(sassy_man|tipping_hand_man)" + DELIM: "💁‍♂️", DELIM + r"(sassy_woman|tipping_hand_woman)" + DELIM: "💁‍♀️", DELIM + r"raising_hand" + DELIM: "🙋", DELIM + r"raising_hand_man" + DELIM: "🙋‍♂️", DELIM + r"raising_hand_woman" + DELIM: "🙋‍♀️", DELIM + r"deaf_person" + DELIM: "🧏", DELIM + r"deaf_man" + DELIM: "🧏‍♂️", DELIM + r"deaf_woman" + DELIM: "🧏‍♀️", DELIM + r"bow" + DELIM: "🙇", DELIM + r"bowing_man" + DELIM: "🙇‍♂️", DELIM + r"bowing_woman" + DELIM: "🙇‍♀️", DELIM + r"facepalm" + DELIM: "🤦", DELIM + r"man_facepalming" + DELIM: "🤦‍♂️", DELIM + r"woman_facepalming" + DELIM: "🤦‍♀️", DELIM + r"shrug" + DELIM: "🤷", DELIM + r"man_shrugging" + DELIM: "🤷‍♂️", DELIM + r"woman_shrugging" + DELIM: "🤷‍♀️", # # Person Role # DELIM + r"health_worker" + DELIM: "🧑‍⚕️", DELIM + r"man_health_worker" + DELIM: "👨‍⚕️", DELIM + r"woman_health_worker" + DELIM: "👩‍⚕️", DELIM + r"student" + DELIM: "🧑‍🎓", DELIM + r"man_student" + DELIM: "👨‍🎓", DELIM + r"woman_student" + DELIM: "👩‍🎓", DELIM + r"teacher" + DELIM: "🧑‍🏫", DELIM + r"man_teacher" + DELIM: "👨‍🏫", DELIM + r"woman_teacher" + DELIM: "👩‍🏫", DELIM + r"judge" + DELIM: "🧑‍⚖️", DELIM + r"man_judge" + DELIM: "👨‍⚖️", DELIM + r"woman_judge" + DELIM: "👩‍⚖️", DELIM + r"farmer" + DELIM: "🧑‍🌾", DELIM + r"man_farmer" + DELIM: "👨‍🌾", DELIM + r"woman_farmer" + DELIM: "👩‍🌾", DELIM + r"cook" + DELIM: "🧑‍🍳", DELIM + r"man_cook" + DELIM: "👨‍🍳", DELIM + r"woman_cook" + DELIM: "👩‍🍳", DELIM + r"mechanic" + DELIM: "🧑‍🔧", DELIM + r"man_mechanic" + DELIM: "👨‍🔧", DELIM + r"woman_mechanic" + DELIM: "👩‍🔧", DELIM + r"factory_worker" + DELIM: "🧑‍🏭", DELIM + r"man_factory_worker" + DELIM: "👨‍🏭", DELIM + r"woman_factory_worker" + DELIM: "👩‍🏭", DELIM + r"office_worker" + DELIM: "🧑‍💼", DELIM + r"man_office_worker" + DELIM: "👨‍💼", DELIM + r"woman_office_worker" + DELIM: "👩‍💼", DELIM + r"scientist" + DELIM: "🧑‍🔬", DELIM + r"man_scientist" + DELIM: "👨‍🔬", DELIM + r"woman_scientist" + DELIM: "👩‍🔬", DELIM + r"technologist" + DELIM: "🧑‍💻", DELIM + r"man_technologist" + DELIM: "👨‍💻", DELIM + r"woman_technologist" + DELIM: "👩‍💻", DELIM + r"singer" + DELIM: "🧑‍🎤", DELIM + r"man_singer" + DELIM: "👨‍🎤", DELIM + r"woman_singer" + DELIM: "👩‍🎤", DELIM + r"artist" + DELIM: "🧑‍🎨", DELIM + r"man_artist" + DELIM: "👨‍🎨", DELIM + r"woman_artist" + DELIM: "👩‍🎨", DELIM + r"pilot" + DELIM: "🧑‍✈️", DELIM + r"man_pilot" + DELIM: "👨‍✈️", DELIM + r"woman_pilot" + DELIM: "👩‍✈️", DELIM + r"astronaut" + DELIM: "🧑‍🚀", DELIM + r"man_astronaut" + DELIM: "👨‍🚀", DELIM + r"woman_astronaut" + DELIM: "👩‍🚀", DELIM + r"firefighter" + DELIM: "🧑‍🚒", DELIM + r"man_firefighter" + DELIM: "👨‍🚒", DELIM + r"woman_firefighter" + DELIM: "👩‍🚒", DELIM + r"cop" + DELIM: "👮", DELIM + r"police(_officer|man)" + DELIM: "👮‍♂️", DELIM + r"policewoman" + DELIM: "👮‍♀️", DELIM + r"detective" + DELIM: "🕵️", DELIM + r"male_detective" + DELIM: "🕵️‍♂️", DELIM + r"female_detective" + DELIM: "🕵️‍♀️", DELIM + r"guard" + DELIM: "💂", DELIM + r"guardsman" + DELIM: "💂‍♂️", DELIM + r"guardswoman" + DELIM: "💂‍♀️", DELIM + r"ninja" + DELIM: "🥷", DELIM + r"construction_worker" + DELIM: "👷", DELIM + r"construction_worker_man" + DELIM: "👷‍♂️", DELIM + r"construction_worker_woman" + DELIM: "👷‍♀️", DELIM + r"prince" + DELIM: "🤴", DELIM + r"princess" + DELIM: "👸", DELIM + r"person_with_turban" + DELIM: "👳", DELIM + r"man_with_turban" + DELIM: "👳‍♂️", DELIM + r"woman_with_turban" + DELIM: "👳‍♀️", DELIM + r"man_with_gua_pi_mao" + DELIM: "👲", DELIM + r"woman_with_headscarf" + DELIM: "🧕", DELIM + r"person_in_tuxedo" + DELIM: "🤵", DELIM + r"man_in_tuxedo" + DELIM: "🤵‍♂️", DELIM + r"woman_in_tuxedo" + DELIM: "🤵‍♀️", DELIM + r"person_with_veil" + DELIM: "👰", DELIM + r"man_with_veil" + DELIM: "👰‍♂️", DELIM + r"(bride|woman)_with_veil" + DELIM: "👰‍♀️", DELIM + r"pregnant_woman" + DELIM: "🤰", DELIM + r"breast_feeding" + DELIM: "🤱", DELIM + r"woman_feeding_baby" + DELIM: "👩‍🍼", DELIM + r"man_feeding_baby" + DELIM: "👨‍🍼", DELIM + r"person_feeding_baby" + DELIM: "🧑‍🍼", # # Person Fantasy # DELIM + r"angel" + DELIM: "👼", DELIM + r"santa" + DELIM: "🎅", DELIM + r"mrs_claus" + DELIM: "🤶", DELIM + r"mx_claus" + DELIM: "🧑‍🎄", DELIM + r"superhero" + DELIM: "🦸", DELIM + r"superhero_man" + DELIM: "🦸‍♂️", DELIM + r"superhero_woman" + DELIM: "🦸‍♀️", DELIM + r"supervillain" + DELIM: "🦹", DELIM + r"supervillain_man" + DELIM: "🦹‍♂️", DELIM + r"supervillain_woman" + DELIM: "🦹‍♀️", DELIM + r"mage" + DELIM: "🧙", DELIM + r"mage_man" + DELIM: "🧙‍♂️", DELIM + r"mage_woman" + DELIM: "🧙‍♀️", DELIM + r"fairy" + DELIM: "🧚", DELIM + r"fairy_man" + DELIM: "🧚‍♂️", DELIM + r"fairy_woman" + DELIM: "🧚‍♀️", DELIM + r"vampire" + DELIM: "🧛", DELIM + r"vampire_man" + DELIM: "🧛‍♂️", DELIM + r"vampire_woman" + DELIM: "🧛‍♀️", DELIM + r"merperson" + DELIM: "🧜", DELIM + r"merman" + DELIM: "🧜‍♂️", DELIM + r"mermaid" + DELIM: "🧜‍♀️", DELIM + r"elf" + DELIM: "🧝", DELIM + r"elf_man" + DELIM: "🧝‍♂️", DELIM + r"elf_woman" + DELIM: "🧝‍♀️", DELIM + r"genie" + DELIM: "🧞", DELIM + r"genie_man" + DELIM: "🧞‍♂️", DELIM + r"genie_woman" + DELIM: "🧞‍♀️", DELIM + r"zombie" + DELIM: "🧟", DELIM + r"zombie_man" + DELIM: "🧟‍♂️", DELIM + r"zombie_woman" + DELIM: "🧟‍♀️", # # Person Activity # DELIM + r"massage" + DELIM: "💆", DELIM + r"massage_man" + DELIM: "💆‍♂️", DELIM + r"massage_woman" + DELIM: "💆‍♀️", DELIM + r"haircut" + DELIM: "💇", DELIM + r"haircut_man" + DELIM: "💇‍♂️", DELIM + r"haircut_woman" + DELIM: "💇‍♀️", DELIM + r"walking" + DELIM: "🚶", DELIM + r"walking_man" + DELIM: "🚶‍♂️", DELIM + r"walking_woman" + DELIM: "🚶‍♀️", DELIM + r"standing_person" + DELIM: "🧍", DELIM + r"standing_man" + DELIM: "🧍‍♂️", DELIM + r"standing_woman" + DELIM: "🧍‍♀️", DELIM + r"kneeling_person" + DELIM: "🧎", DELIM + r"kneeling_man" + DELIM: "🧎‍♂️", DELIM + r"kneeling_woman" + DELIM: "🧎‍♀️", DELIM + r"person_with_probing_cane" + DELIM: "🧑‍🦯", DELIM + r"man_with_probing_cane" + DELIM: "👨‍🦯", DELIM + r"woman_with_probing_cane" + DELIM: "👩‍🦯", DELIM + r"person_in_motorized_wheelchair" + DELIM: "🧑‍🦼", DELIM + r"man_in_motorized_wheelchair" + DELIM: "👨‍🦼", DELIM + r"woman_in_motorized_wheelchair" + DELIM: "👩‍🦼", DELIM + r"person_in_manual_wheelchair" + DELIM: "🧑‍🦽", DELIM + r"man_in_manual_wheelchair" + DELIM: "👨‍🦽", DELIM + r"woman_in_manual_wheelchair" + DELIM: "👩‍🦽", DELIM + r"runn(er|ing)" + DELIM: "🏃", DELIM + r"running_man" + DELIM: "🏃‍♂️", DELIM + r"running_woman" + DELIM: "🏃‍♀️", DELIM + r"(dancer|woman_dancing)" + DELIM: "💃", DELIM + r"man_dancing" + DELIM: "🕺", DELIM + r"business_suit_levitating" + DELIM: "🕴️", DELIM + r"dancers" + DELIM: "👯", DELIM + r"dancing_men" + DELIM: "👯‍♂️", DELIM + r"dancing_women" + DELIM: "👯‍♀️", DELIM + r"sauna_person" + DELIM: "🧖", DELIM + r"sauna_man" + DELIM: "🧖‍♂️", DELIM + r"sauna_woman" + DELIM: "🧖‍♀️", DELIM + r"climbing" + DELIM: "🧗", DELIM + r"climbing_man" + DELIM: "🧗‍♂️", DELIM + r"climbing_woman" + DELIM: "🧗‍♀️", # # Person Sport # DELIM + r"person_fencing" + DELIM: "🤺", DELIM + r"horse_racing" + DELIM: "🏇", DELIM + r"skier" + DELIM: "⛷️", DELIM + r"snowboarder" + DELIM: "🏂", DELIM + r"golfing" + DELIM: "🏌️", DELIM + r"golfing_man" + DELIM: "🏌️‍♂️", DELIM + r"golfing_woman" + DELIM: "🏌️‍♀️", DELIM + r"surfer" + DELIM: "🏄", DELIM + r"surfing_man" + DELIM: "🏄‍♂️", DELIM + r"surfing_woman" + DELIM: "🏄‍♀️", DELIM + r"rowboat" + DELIM: "🚣", DELIM + r"rowing_man" + DELIM: "🚣‍♂️", DELIM + r"rowing_woman" + DELIM: "🚣‍♀️", DELIM + r"swimmer" + DELIM: "🏊", DELIM + r"swimming_man" + DELIM: "🏊‍♂️", DELIM + r"swimming_woman" + DELIM: "🏊‍♀️", DELIM + r"bouncing_ball_person" + DELIM: "⛹️", DELIM + r"(basketball|bouncing_ball)_man" + DELIM: "⛹️‍♂️", DELIM + r"(basketball|bouncing_ball)_woman" + DELIM: "⛹️‍♀️", DELIM + r"weight_lifting" + DELIM: "🏋️", DELIM + r"weight_lifting_man" + DELIM: "🏋️‍♂️", DELIM + r"weight_lifting_woman" + DELIM: "🏋️‍♀️", DELIM + r"bicyclist" + DELIM: "🚴", DELIM + r"biking_man" + DELIM: "🚴‍♂️", DELIM + r"biking_woman" + DELIM: "🚴‍♀️", DELIM + r"mountain_bicyclist" + DELIM: "🚵", DELIM + r"mountain_biking_man" + DELIM: "🚵‍♂️", DELIM + r"mountain_biking_woman" + DELIM: "🚵‍♀️", DELIM + r"cartwheeling" + DELIM: "🤸", DELIM + r"man_cartwheeling" + DELIM: "🤸‍♂️", DELIM + r"woman_cartwheeling" + DELIM: "🤸‍♀️", DELIM + r"wrestling" + DELIM: "🤼", DELIM + r"men_wrestling" + DELIM: "🤼‍♂️", DELIM + r"women_wrestling" + DELIM: "🤼‍♀️", DELIM + r"water_polo" + DELIM: "🤽", DELIM + r"man_playing_water_polo" + DELIM: "🤽‍♂️", DELIM + r"woman_playing_water_polo" + DELIM: "🤽‍♀️", DELIM + r"handball_person" + DELIM: "🤾", DELIM + r"man_playing_handball" + DELIM: "🤾‍♂️", DELIM + r"woman_playing_handball" + DELIM: "🤾‍♀️", DELIM + r"juggling_person" + DELIM: "🤹", DELIM + r"man_juggling" + DELIM: "🤹‍♂️", DELIM + r"woman_juggling" + DELIM: "🤹‍♀️", # # Person Resting # DELIM + r"lotus_position" + DELIM: "🧘", DELIM + r"lotus_position_man" + DELIM: "🧘‍♂️", DELIM + r"lotus_position_woman" + DELIM: "🧘‍♀️", DELIM + r"bath" + DELIM: "🛀", DELIM + r"sleeping_bed" + DELIM: "🛌", # # Family # DELIM + r"people_holding_hands" + DELIM: "🧑‍🤝‍🧑", DELIM + r"two_women_holding_hands" + DELIM: "👭", DELIM + r"couple" + DELIM: "👫", DELIM + r"two_men_holding_hands" + DELIM: "👬", DELIM + r"couplekiss" + DELIM: "💏", DELIM + r"couplekiss_man_woman" + DELIM: "👩‍❤️‍💋‍👨", DELIM + r"couplekiss_man_man" + DELIM: "👨‍❤️‍💋‍👨", DELIM + r"couplekiss_woman_woman" + DELIM: "👩‍❤️‍💋‍👩", DELIM + r"couple_with_heart" + DELIM: "💑", DELIM + r"couple_with_heart_woman_man" + DELIM: "👩‍❤️‍👨", DELIM + r"couple_with_heart_man_man" + DELIM: "👨‍❤️‍👨", DELIM + r"couple_with_heart_woman_woman" + DELIM: "👩‍❤️‍👩", DELIM + r"family_man_woman_boy" + DELIM: "👨‍👩‍👦", DELIM + r"family_man_woman_girl" + DELIM: "👨‍👩‍👧", DELIM + r"family_man_woman_girl_boy" + DELIM: "👨‍👩‍👧‍👦", DELIM + r"family_man_woman_boy_boy" + DELIM: "👨‍👩‍👦‍👦", DELIM + r"family_man_woman_girl_girl" + DELIM: "👨‍👩‍👧‍👧", DELIM + r"family_man_man_boy" + DELIM: "👨‍👨‍👦", DELIM + r"family_man_man_girl" + DELIM: "👨‍👨‍👧", DELIM + r"family_man_man_girl_boy" + DELIM: "👨‍👨‍👧‍👦", DELIM + r"family_man_man_boy_boy" + DELIM: "👨‍👨‍👦‍👦", DELIM + r"family_man_man_girl_girl" + DELIM: "👨‍👨‍👧‍👧", DELIM + r"family_woman_woman_boy" + DELIM: "👩‍👩‍👦", DELIM + r"family_woman_woman_girl" + DELIM: "👩‍👩‍👧", DELIM + r"family_woman_woman_girl_boy" + DELIM: "👩‍👩‍👧‍👦", DELIM + r"family_woman_woman_boy_boy" + DELIM: "👩‍👩‍👦‍👦", DELIM + r"family_woman_woman_girl_girl" + DELIM: "👩‍👩‍👧‍👧", DELIM + r"family_man_boy" + DELIM: "👨‍👦", DELIM + r"family_man_boy_boy" + DELIM: "👨‍👦‍👦", DELIM + r"family_man_girl" + DELIM: "👨‍👧", DELIM + r"family_man_girl_boy" + DELIM: "👨‍👧‍👦", DELIM + r"family_man_girl_girl" + DELIM: "👨‍👧‍👧", DELIM + r"family_woman_boy" + DELIM: "👩‍👦", DELIM + r"family_woman_boy_boy" + DELIM: "👩‍👦‍👦", DELIM + r"family_woman_girl" + DELIM: "👩‍👧", DELIM + r"family_woman_girl_boy" + DELIM: "👩‍👧‍👦", DELIM + r"family_woman_girl_girl" + DELIM: "👩‍👧‍👧", # # Person Symbol # DELIM + r"speaking_head" + DELIM: "🗣️", DELIM + r"bust_in_silhouette" + DELIM: "👤", DELIM + r"busts_in_silhouette" + DELIM: "👥", DELIM + r"people_hugging" + DELIM: "🫂", DELIM + r"family" + DELIM: "👪", DELIM + r"footprints" + DELIM: "👣", # # Animal Mammal # DELIM + r"monkey_face" + DELIM: "🐵", DELIM + r"monkey" + DELIM: "🐒", DELIM + r"gorilla" + DELIM: "🦍", DELIM + r"orangutan" + DELIM: "🦧", DELIM + r"dog" + DELIM: "🐶", DELIM + r"dog2" + DELIM: "🐕", DELIM + r"guide_dog" + DELIM: "🦮", DELIM + r"service_dog" + DELIM: "🐕‍🦺", DELIM + r"poodle" + DELIM: "🐩", DELIM + r"wolf" + DELIM: "🐺", DELIM + r"fox_face" + DELIM: "🦊", DELIM + r"raccoon" + DELIM: "🦝", DELIM + r"cat" + DELIM: "🐱", DELIM + r"cat2" + DELIM: "🐈", DELIM + r"black_cat" + DELIM: "🐈‍⬛", DELIM + r"lion" + DELIM: "🦁", DELIM + r"tiger" + DELIM: "🐯", DELIM + r"tiger2" + DELIM: "🐅", DELIM + r"leopard" + DELIM: "🐆", DELIM + r"horse" + DELIM: "🐴", DELIM + r"racehorse" + DELIM: "🐎", DELIM + r"unicorn" + DELIM: "🦄", DELIM + r"zebra" + DELIM: "🦓", DELIM + r"deer" + DELIM: "🦌", DELIM + r"bison" + DELIM: "🦬", DELIM + r"cow" + DELIM: "🐮", DELIM + r"ox" + DELIM: "🐂", DELIM + r"water_buffalo" + DELIM: "🐃", DELIM + r"cow2" + DELIM: "🐄", DELIM + r"pig" + DELIM: "🐷", DELIM + r"pig2" + DELIM: "🐖", DELIM + r"boar" + DELIM: "🐗", DELIM + r"pig_nose" + DELIM: "🐽", DELIM + r"ram" + DELIM: "🐏", DELIM + r"sheep" + DELIM: "🐑", DELIM + r"goat" + DELIM: "🐐", DELIM + r"dromedary_camel" + DELIM: "🐪", DELIM + r"camel" + DELIM: "🐫", DELIM + r"llama" + DELIM: "🦙", DELIM + r"giraffe" + DELIM: "🦒", DELIM + r"elephant" + DELIM: "🐘", DELIM + r"mammoth" + DELIM: "🦣", DELIM + r"rhinoceros" + DELIM: "🦏", DELIM + r"hippopotamus" + DELIM: "🦛", DELIM + r"mouse" + DELIM: "🐭", DELIM + r"mouse2" + DELIM: "🐁", DELIM + r"rat" + DELIM: "🐀", DELIM + r"hamster" + DELIM: "🐹", DELIM + r"rabbit" + DELIM: "🐰", DELIM + r"rabbit2" + DELIM: "🐇", DELIM + r"chipmunk" + DELIM: "🐿️", DELIM + r"beaver" + DELIM: "🦫", DELIM + r"hedgehog" + DELIM: "🦔", DELIM + r"bat" + DELIM: "🦇", DELIM + r"bear" + DELIM: "🐻", DELIM + r"polar_bear" + DELIM: "🐻‍❄️", DELIM + r"koala" + DELIM: "🐨", DELIM + r"panda_face" + DELIM: "🐼", DELIM + r"sloth" + DELIM: "🦥", DELIM + r"otter" + DELIM: "🦦", DELIM + r"skunk" + DELIM: "🦨", DELIM + r"kangaroo" + DELIM: "🦘", DELIM + r"badger" + DELIM: "🦡", DELIM + r"(feet|paw_prints)" + DELIM: "🐾", # # Animal Bird # DELIM + r"turkey" + DELIM: "🦃", DELIM + r"chicken" + DELIM: "🐔", DELIM + r"rooster" + DELIM: "🐓", DELIM + r"hatching_chick" + DELIM: "🐣", DELIM + r"baby_chick" + DELIM: "🐤", DELIM + r"hatched_chick" + DELIM: "🐥", DELIM + r"bird" + DELIM: "🐦", DELIM + r"penguin" + DELIM: "🐧", DELIM + r"dove" + DELIM: "🕊️", DELIM + r"eagle" + DELIM: "🦅", DELIM + r"duck" + DELIM: "🦆", DELIM + r"swan" + DELIM: "🦢", DELIM + r"owl" + DELIM: "🦉", DELIM + r"dodo" + DELIM: "🦤", DELIM + r"feather" + DELIM: "🪶", DELIM + r"flamingo" + DELIM: "🦩", DELIM + r"peacock" + DELIM: "🦚", DELIM + r"parrot" + DELIM: "🦜", # # Animal Amphibian # DELIM + r"frog" + DELIM: "🐸", # # Animal Reptile # DELIM + r"crocodile" + DELIM: "🐊", DELIM + r"turtle" + DELIM: "🐢", DELIM + r"lizard" + DELIM: "🦎", DELIM + r"snake" + DELIM: "🐍", DELIM + r"dragon_face" + DELIM: "🐲", DELIM + r"dragon" + DELIM: "🐉", DELIM + r"sauropod" + DELIM: "🦕", DELIM + r"t-rex" + DELIM: "🦖", # # Animal Marine # DELIM + r"whale" + DELIM: "🐳", DELIM + r"whale2" + DELIM: "🐋", DELIM + r"dolphin" + DELIM: "🐬", DELIM + r"(seal|flipper)" + DELIM: "🦭", DELIM + r"fish" + DELIM: "🐟", DELIM + r"tropical_fish" + DELIM: "🐠", DELIM + r"blowfish" + DELIM: "🐡", DELIM + r"shark" + DELIM: "🦈", DELIM + r"octopus" + DELIM: "🐙", DELIM + r"shell" + DELIM: "🐚", # # Animal Bug # DELIM + r"snail" + DELIM: "🐌", DELIM + r"butterfly" + DELIM: "🦋", DELIM + r"bug" + DELIM: "🐛", DELIM + r"ant" + DELIM: "🐜", DELIM + r"bee" + DELIM: "🐝", DELIM + r"honeybee" + DELIM: "🪲", DELIM + r"(lady_)?beetle" + DELIM: "🐞", DELIM + r"cricket" + DELIM: "🦗", DELIM + r"cockroach" + DELIM: "🪳", DELIM + r"spider" + DELIM: "🕷️", DELIM + r"spider_web" + DELIM: "🕸️", DELIM + r"scorpion" + DELIM: "🦂", DELIM + r"mosquito" + DELIM: "🦟", DELIM + r"fly" + DELIM: "🪰", DELIM + r"worm" + DELIM: "🪱", DELIM + r"microbe" + DELIM: "🦠", # # Plant Flower # DELIM + r"bouquet" + DELIM: "💐", DELIM + r"cherry_blossom" + DELIM: "🌸", DELIM + r"white_flower" + DELIM: "💮", DELIM + r"rosette" + DELIM: "🏵️", DELIM + r"rose" + DELIM: "🌹", DELIM + r"wilted_flower" + DELIM: "🥀", DELIM + r"hibiscus" + DELIM: "🌺", DELIM + r"sunflower" + DELIM: "🌻", DELIM + r"blossom" + DELIM: "🌼", DELIM + r"tulip" + DELIM: "🌷", # # Plant Other # DELIM + r"seedling" + DELIM: "🌱", DELIM + r"potted_plant" + DELIM: "🪴", DELIM + r"evergreen_tree" + DELIM: "🌲", DELIM + r"deciduous_tree" + DELIM: "🌳", DELIM + r"palm_tree" + DELIM: "🌴", DELIM + r"cactus" + DELIM: "🌵", DELIM + r"ear_of_rice" + DELIM: "🌾", DELIM + r"herb" + DELIM: "🌿", DELIM + r"shamrock" + DELIM: "☘️", DELIM + r"four_leaf_clover" + DELIM: "🍀", DELIM + r"maple_leaf" + DELIM: "🍁", DELIM + r"fallen_leaf" + DELIM: "🍂", DELIM + r"leaves" + DELIM: "🍃", DELIM + r"mushroom" + DELIM: "🍄", # # Food Fruit # DELIM + r"grapes" + DELIM: "🍇", DELIM + r"melon" + DELIM: "🍈", DELIM + r"watermelon" + DELIM: "🍉", DELIM + r"(orange|mandarin|tangerine)" + DELIM: "🍊", DELIM + r"lemon" + DELIM: "🍋", DELIM + r"banana" + DELIM: "🍌", DELIM + r"pineapple" + DELIM: "🍍", DELIM + r"mango" + DELIM: "🥭", DELIM + r"apple" + DELIM: "🍎", DELIM + r"green_apple" + DELIM: "🍏", DELIM + r"pear" + DELIM: "🍐", DELIM + r"peach" + DELIM: "🍑", DELIM + r"cherries" + DELIM: "🍒", DELIM + r"strawberry" + DELIM: "🍓", DELIM + r"blueberries" + DELIM: "🫐", DELIM + r"kiwi_fruit" + DELIM: "🥝", DELIM + r"tomato" + DELIM: "🍅", DELIM + r"olive" + DELIM: "🫒", DELIM + r"coconut" + DELIM: "🥥", # # Food Vegetable # DELIM + r"avocado" + DELIM: "🥑", DELIM + r"eggplant" + DELIM: "🍆", DELIM + r"potato" + DELIM: "🥔", DELIM + r"carrot" + DELIM: "🥕", DELIM + r"corn" + DELIM: "🌽", DELIM + r"hot_pepper" + DELIM: "🌶️", DELIM + r"bell_pepper" + DELIM: "🫑", DELIM + r"cucumber" + DELIM: "🥒", DELIM + r"leafy_green" + DELIM: "🥬", DELIM + r"broccoli" + DELIM: "🥦", DELIM + r"garlic" + DELIM: "🧄", DELIM + r"onion" + DELIM: "🧅", DELIM + r"peanuts" + DELIM: "🥜", DELIM + r"chestnut" + DELIM: "🌰", # # Food Prepared # DELIM + r"bread" + DELIM: "🍞", DELIM + r"croissant" + DELIM: "🥐", DELIM + r"baguette_bread" + DELIM: "🥖", DELIM + r"flatbread" + DELIM: "🫓", DELIM + r"pretzel" + DELIM: "🥨", DELIM + r"bagel" + DELIM: "🥯", DELIM + r"pancakes" + DELIM: "🥞", DELIM + r"waffle" + DELIM: "🧇", DELIM + r"cheese" + DELIM: "🧀", DELIM + r"meat_on_bone" + DELIM: "🍖", DELIM + r"poultry_leg" + DELIM: "🍗", DELIM + r"cut_of_meat" + DELIM: "🥩", DELIM + r"bacon" + DELIM: "🥓", DELIM + r"hamburger" + DELIM: "🍔", DELIM + r"fries" + DELIM: "🍟", DELIM + r"pizza" + DELIM: "🍕", DELIM + r"hotdog" + DELIM: "🌭", DELIM + r"sandwich" + DELIM: "🥪", DELIM + r"taco" + DELIM: "🌮", DELIM + r"burrito" + DELIM: "🌯", DELIM + r"tamale" + DELIM: "🫔", DELIM + r"stuffed_flatbread" + DELIM: "🥙", DELIM + r"falafel" + DELIM: "🧆", DELIM + r"egg" + DELIM: "🥚", DELIM + r"fried_egg" + DELIM: "🍳", DELIM + r"shallow_pan_of_food" + DELIM: "🥘", DELIM + r"stew" + DELIM: "🍲", DELIM + r"fondue" + DELIM: "🫕", DELIM + r"bowl_with_spoon" + DELIM: "🥣", DELIM + r"green_salad" + DELIM: "🥗", DELIM + r"popcorn" + DELIM: "🍿", DELIM + r"butter" + DELIM: "🧈", DELIM + r"salt" + DELIM: "🧂", DELIM + r"canned_food" + DELIM: "🥫", # # Food Asian # DELIM + r"bento" + DELIM: "🍱", DELIM + r"rice_cracker" + DELIM: "🍘", DELIM + r"rice_ball" + DELIM: "🍙", DELIM + r"rice" + DELIM: "🍚", DELIM + r"curry" + DELIM: "🍛", DELIM + r"ramen" + DELIM: "🍜", DELIM + r"spaghetti" + DELIM: "🍝", DELIM + r"sweet_potato" + DELIM: "🍠", DELIM + r"oden" + DELIM: "🍢", DELIM + r"sushi" + DELIM: "🍣", DELIM + r"fried_shrimp" + DELIM: "🍤", DELIM + r"fish_cake" + DELIM: "🍥", DELIM + r"moon_cake" + DELIM: "🥮", DELIM + r"dango" + DELIM: "🍡", DELIM + r"dumpling" + DELIM: "🥟", DELIM + r"fortune_cookie" + DELIM: "🥠", DELIM + r"takeout_box" + DELIM: "🥡", # # Food Marine # DELIM + r"crab" + DELIM: "🦀", DELIM + r"lobster" + DELIM: "🦞", DELIM + r"shrimp" + DELIM: "🦐", DELIM + r"squid" + DELIM: "🦑", DELIM + r"oyster" + DELIM: "🦪", # # Food Sweet # DELIM + r"icecream" + DELIM: "🍦", DELIM + r"shaved_ice" + DELIM: "🍧", DELIM + r"ice_cream" + DELIM: "🍨", DELIM + r"doughnut" + DELIM: "🍩", DELIM + r"cookie" + DELIM: "🍪", DELIM + r"birthday" + DELIM: "🎂", DELIM + r"cake" + DELIM: "🍰", DELIM + r"cupcake" + DELIM: "🧁", DELIM + r"pie" + DELIM: "🥧", DELIM + r"chocolate_bar" + DELIM: "🍫", DELIM + r"candy" + DELIM: "🍬", DELIM + r"lollipop" + DELIM: "🍭", DELIM + r"custard" + DELIM: "🍮", DELIM + r"honey_pot" + DELIM: "🍯", # # Drink # DELIM + r"baby_bottle" + DELIM: "🍼", DELIM + r"milk_glass" + DELIM: "🥛", DELIM + r"coffee" + DELIM: "☕", DELIM + r"teapot" + DELIM: "🫖", DELIM + r"tea" + DELIM: "🍵", DELIM + r"sake" + DELIM: "🍶", DELIM + r"champagne" + DELIM: "🍾", DELIM + r"wine_glass" + DELIM: "🍷", DELIM + r"cocktail" + DELIM: "🍸", DELIM + r"tropical_drink" + DELIM: "🍹", DELIM + r"beer" + DELIM: "🍺", DELIM + r"beers" + DELIM: "🍻", DELIM + r"clinking_glasses" + DELIM: "🥂", DELIM + r"tumbler_glass" + DELIM: "🥃", DELIM + r"cup_with_straw" + DELIM: "🥤", DELIM + r"bubble_tea" + DELIM: "🧋", DELIM + r"beverage_box" + DELIM: "🧃", DELIM + r"mate" + DELIM: "🧉", DELIM + r"ice_cube" + DELIM: "🧊", # # Dishware # DELIM + r"chopsticks" + DELIM: "🥢", DELIM + r"plate_with_cutlery" + DELIM: "🍽️", DELIM + r"fork_and_knife" + DELIM: "🍴", DELIM + r"spoon" + DELIM: "🥄", DELIM + r"(hocho|knife)" + DELIM: "🔪", DELIM + r"amphora" + DELIM: "🏺", # # Place Map # DELIM + r"earth_africa" + DELIM: "🌍", DELIM + r"earth_americas" + DELIM: "🌎", DELIM + r"earth_asia" + DELIM: "🌏", DELIM + r"globe_with_meridians" + DELIM: "🌐", DELIM + r"world_map" + DELIM: "🗺️", DELIM + r"japan" + DELIM: "🗾", DELIM + r"compass" + DELIM: "🧭", # # Place Geographic # DELIM + r"mountain_snow" + DELIM: "🏔️", DELIM + r"mountain" + DELIM: "⛰️", DELIM + r"volcano" + DELIM: "🌋", DELIM + r"mount_fuji" + DELIM: "🗻", DELIM + r"camping" + DELIM: "🏕️", DELIM + r"beach_umbrella" + DELIM: "🏖️", DELIM + r"desert" + DELIM: "🏜️", DELIM + r"desert_island" + DELIM: "🏝️", DELIM + r"national_park" + DELIM: "🏞️", # # Place Building # DELIM + r"stadium" + DELIM: "🏟️", DELIM + r"classical_building" + DELIM: "🏛️", DELIM + r"building_construction" + DELIM: "🏗️", DELIM + r"bricks" + DELIM: "🧱", DELIM + r"rock" + DELIM: "🪨", DELIM + r"wood" + DELIM: "🪵", DELIM + r"hut" + DELIM: "🛖", DELIM + r"houses" + DELIM: "🏘️", DELIM + r"derelict_house" + DELIM: "🏚️", DELIM + r"house" + DELIM: "🏠", DELIM + r"house_with_garden" + DELIM: "🏡", DELIM + r"office" + DELIM: "🏢", DELIM + r"post_office" + DELIM: "🏣", DELIM + r"european_post_office" + DELIM: "🏤", DELIM + r"hospital" + DELIM: "🏥", DELIM + r"bank" + DELIM: "🏦", DELIM + r"hotel" + DELIM: "🏨", DELIM + r"love_hotel" + DELIM: "🏩", DELIM + r"convenience_store" + DELIM: "🏪", DELIM + r"school" + DELIM: "🏫", DELIM + r"department_store" + DELIM: "🏬", DELIM + r"factory" + DELIM: "🏭", DELIM + r"japanese_castle" + DELIM: "🏯", DELIM + r"european_castle" + DELIM: "🏰", DELIM + r"wedding" + DELIM: "💒", DELIM + r"tokyo_tower" + DELIM: "🗼", DELIM + r"statue_of_liberty" + DELIM: "🗽", # # Place Religious # DELIM + r"church" + DELIM: "⛪", DELIM + r"mosque" + DELIM: "🕌", DELIM + r"hindu_temple" + DELIM: "🛕", DELIM + r"synagogue" + DELIM: "🕍", DELIM + r"shinto_shrine" + DELIM: "⛩️", DELIM + r"kaaba" + DELIM: "🕋", # # Place Other # DELIM + r"fountain" + DELIM: "⛲", DELIM + r"tent" + DELIM: "⛺", DELIM + r"foggy" + DELIM: "🌁", DELIM + r"night_with_stars" + DELIM: "🌃", DELIM + r"cityscape" + DELIM: "🏙️", DELIM + r"sunrise_over_mountains" + DELIM: "🌄", DELIM + r"sunrise" + DELIM: "🌅", DELIM + r"city_sunset" + DELIM: "🌆", DELIM + r"city_sunrise" + DELIM: "🌇", DELIM + r"bridge_at_night" + DELIM: "🌉", DELIM + r"hotsprings" + DELIM: "♨️", DELIM + r"carousel_horse" + DELIM: "🎠", DELIM + r"ferris_wheel" + DELIM: "🎡", DELIM + r"roller_coaster" + DELIM: "🎢", DELIM + r"barber" + DELIM: "💈", DELIM + r"circus_tent" + DELIM: "🎪", # # Transport Ground # DELIM + r"steam_locomotive" + DELIM: "🚂", DELIM + r"railway_car" + DELIM: "🚃", DELIM + r"bullettrain_side" + DELIM: "🚄", DELIM + r"bullettrain_front" + DELIM: "🚅", DELIM + r"train2" + DELIM: "🚆", DELIM + r"metro" + DELIM: "🚇", DELIM + r"light_rail" + DELIM: "🚈", DELIM + r"station" + DELIM: "🚉", DELIM + r"tram" + DELIM: "🚊", DELIM + r"monorail" + DELIM: "🚝", DELIM + r"mountain_railway" + DELIM: "🚞", DELIM + r"train" + DELIM: "🚋", DELIM + r"bus" + DELIM: "🚌", DELIM + r"oncoming_bus" + DELIM: "🚍", DELIM + r"trolleybus" + DELIM: "🚎", DELIM + r"minibus" + DELIM: "🚐", DELIM + r"ambulance" + DELIM: "🚑", DELIM + r"fire_engine" + DELIM: "🚒", DELIM + r"police_car" + DELIM: "🚓", DELIM + r"oncoming_police_car" + DELIM: "🚔", DELIM + r"taxi" + DELIM: "🚕", DELIM + r"oncoming_taxi" + DELIM: "🚖", DELIM + r"car" + DELIM: "🚗", DELIM + r"(red_car|oncoming_automobile)" + DELIM: "🚘", DELIM + r"blue_car" + DELIM: "🚙", DELIM + r"pickup_truck" + DELIM: "🛻", DELIM + r"truck" + DELIM: "🚚", DELIM + r"articulated_lorry" + DELIM: "🚛", DELIM + r"tractor" + DELIM: "🚜", DELIM + r"racing_car" + DELIM: "🏎️", DELIM + r"motorcycle" + DELIM: "🏍️", DELIM + r"motor_scooter" + DELIM: "🛵", DELIM + r"manual_wheelchair" + DELIM: "🦽", DELIM + r"motorized_wheelchair" + DELIM: "🦼", DELIM + r"auto_rickshaw" + DELIM: "🛺", DELIM + r"bike" + DELIM: "🚲", DELIM + r"kick_scooter" + DELIM: "🛴", DELIM + r"skateboard" + DELIM: "🛹", DELIM + r"roller_skate" + DELIM: "🛼", DELIM + r"busstop" + DELIM: "🚏", DELIM + r"motorway" + DELIM: "🛣️", DELIM + r"railway_track" + DELIM: "🛤️", DELIM + r"oil_drum" + DELIM: "🛢️", DELIM + r"fuelpump" + DELIM: "⛽", DELIM + r"rotating_light" + DELIM: "🚨", DELIM + r"traffic_light" + DELIM: "🚥", DELIM + r"vertical_traffic_light" + DELIM: "🚦", DELIM + r"stop_sign" + DELIM: "🛑", DELIM + r"construction" + DELIM: "🚧", # # Transport Water # DELIM + r"anchor" + DELIM: "⚓", DELIM + r"(sailboat|boat)" + DELIM: "⛵", DELIM + r"canoe" + DELIM: "🛶", DELIM + r"speedboat" + DELIM: "🚤", DELIM + r"passenger_ship" + DELIM: "🛳️", DELIM + r"ferry" + DELIM: "⛴️", DELIM + r"motor_boat" + DELIM: "🛥️", DELIM + r"ship" + DELIM: "🚢", # # Transport Air # DELIM + r"airplane" + DELIM: "✈️", DELIM + r"small_airplane" + DELIM: "🛩️", DELIM + r"flight_departure" + DELIM: "🛫", DELIM + r"flight_arrival" + DELIM: "🛬", DELIM + r"parachute" + DELIM: "🪂", DELIM + r"seat" + DELIM: "💺", DELIM + r"helicopter" + DELIM: "🚁", DELIM + r"suspension_railway" + DELIM: "🚟", DELIM + r"mountain_cableway" + DELIM: "🚠", DELIM + r"aerial_tramway" + DELIM: "🚡", DELIM + r"artificial_satellite" + DELIM: "🛰️", DELIM + r"rocket" + DELIM: "🚀", DELIM + r"flying_saucer" + DELIM: "🛸", # # Hotel # DELIM + r"bellhop_bell" + DELIM: "🛎️", DELIM + r"luggage" + DELIM: "🧳", # # Time # DELIM + r"hourglass" + DELIM: "⌛", DELIM + r"hourglass_flowing_sand" + DELIM: "⏳", DELIM + r"watch" + DELIM: "⌚", DELIM + r"alarm_clock" + DELIM: "⏰", DELIM + r"stopwatch" + DELIM: "⏱️", DELIM + r"timer_clock" + DELIM: "⏲️", DELIM + r"mantelpiece_clock" + DELIM: "🕰️", DELIM + r"clock12" + DELIM: "🕛", DELIM + r"clock1230" + DELIM: "🕧", DELIM + r"clock1" + DELIM: "🕐", DELIM + r"clock130" + DELIM: "🕜", DELIM + r"clock2" + DELIM: "🕑", DELIM + r"clock230" + DELIM: "🕝", DELIM + r"clock3" + DELIM: "🕒", DELIM + r"clock330" + DELIM: "🕞", DELIM + r"clock4" + DELIM: "🕓", DELIM + r"clock430" + DELIM: "🕟", DELIM + r"clock5" + DELIM: "🕔", DELIM + r"clock530" + DELIM: "🕠", DELIM + r"clock6" + DELIM: "🕕", DELIM + r"clock630" + DELIM: "🕡", DELIM + r"clock7" + DELIM: "🕖", DELIM + r"clock730" + DELIM: "🕢", DELIM + r"clock8" + DELIM: "🕗", DELIM + r"clock830" + DELIM: "🕣", DELIM + r"clock9" + DELIM: "🕘", DELIM + r"clock930" + DELIM: "🕤", DELIM + r"clock10" + DELIM: "🕙", DELIM + r"clock1030" + DELIM: "🕥", DELIM + r"clock11" + DELIM: "🕚", DELIM + r"clock1130" + DELIM: "🕦", # Sky & Weather DELIM + r"new_moon" + DELIM: "🌑", DELIM + r"waxing_crescent_moon" + DELIM: "🌒", DELIM + r"first_quarter_moon" + DELIM: "🌓", DELIM + r"moon" + DELIM: "🌔", DELIM + r"(waxing_gibbous_moon|full_moon)" + DELIM: "🌕", DELIM + r"waning_gibbous_moon" + DELIM: "🌖", DELIM + r"last_quarter_moon" + DELIM: "🌗", DELIM + r"waning_crescent_moon" + DELIM: "🌘", DELIM + r"crescent_moon" + DELIM: "🌙", DELIM + r"new_moon_with_face" + DELIM: "🌚", DELIM + r"first_quarter_moon_with_face" + DELIM: "🌛", DELIM + r"last_quarter_moon_with_face" + DELIM: "🌜", DELIM + r"thermometer" + DELIM: "🌡️", DELIM + r"sunny" + DELIM: "☀️", DELIM + r"full_moon_with_face" + DELIM: "🌝", DELIM + r"sun_with_face" + DELIM: "🌞", DELIM + r"ringed_planet" + DELIM: "🪐", DELIM + r"star" + DELIM: "⭐", DELIM + r"star2" + DELIM: "🌟", DELIM + r"stars" + DELIM: "🌠", DELIM + r"milky_way" + DELIM: "🌌", DELIM + r"cloud" + DELIM: "☁️", DELIM + r"partly_sunny" + DELIM: "⛅", DELIM + r"cloud_with_lightning_and_rain" + DELIM: "⛈️", DELIM + r"sun_behind_small_cloud" + DELIM: "🌤️", DELIM + r"sun_behind_large_cloud" + DELIM: "🌥️", DELIM + r"sun_behind_rain_cloud" + DELIM: "🌦️", DELIM + r"cloud_with_rain" + DELIM: "🌧️", DELIM + r"cloud_with_snow" + DELIM: "🌨️", DELIM + r"cloud_with_lightning" + DELIM: "🌩️", DELIM + r"tornado" + DELIM: "🌪️", DELIM + r"fog" + DELIM: "🌫️", DELIM + r"wind_face" + DELIM: "🌬️", DELIM + r"cyclone" + DELIM: "🌀", DELIM + r"rainbow" + DELIM: "🌈", DELIM + r"closed_umbrella" + DELIM: "🌂", DELIM + r"open_umbrella" + DELIM: "☂️", DELIM + r"umbrella" + DELIM: "☔", DELIM + r"parasol_on_ground" + DELIM: "⛱️", DELIM + r"zap" + DELIM: "⚡", DELIM + r"snowflake" + DELIM: "❄️", DELIM + r"snowman_with_snow" + DELIM: "☃️", DELIM + r"snowman" + DELIM: "⛄", DELIM + r"comet" + DELIM: "☄️", DELIM + r"fire" + DELIM: "🔥", DELIM + r"droplet" + DELIM: "💧", DELIM + r"ocean" + DELIM: "🌊", # # Event # DELIM + r"jack_o_lantern" + DELIM: "🎃", DELIM + r"christmas_tree" + DELIM: "🎄", DELIM + r"fireworks" + DELIM: "🎆", DELIM + r"sparkler" + DELIM: "🎇", DELIM + r"firecracker" + DELIM: "🧨", DELIM + r"sparkles" + DELIM: "✨", DELIM + r"balloon" + DELIM: "🎈", DELIM + r"tada" + DELIM: "🎉", DELIM + r"confetti_ball" + DELIM: "🎊", DELIM + r"tanabata_tree" + DELIM: "🎋", DELIM + r"bamboo" + DELIM: "🎍", DELIM + r"dolls" + DELIM: "🎎", DELIM + r"flags" + DELIM: "🎏", DELIM + r"wind_chime" + DELIM: "🎐", DELIM + r"rice_scene" + DELIM: "🎑", DELIM + r"red_envelope" + DELIM: "🧧", DELIM + r"ribbon" + DELIM: "🎀", DELIM + r"gift" + DELIM: "🎁", DELIM + r"reminder_ribbon" + DELIM: "🎗️", DELIM + r"tickets" + DELIM: "🎟️", DELIM + r"ticket" + DELIM: "🎫", # # Award Medal # DELIM + r"medal_military" + DELIM: "🎖️", DELIM + r"trophy" + DELIM: "🏆", DELIM + r"medal_sports" + DELIM: "🏅", DELIM + r"1st_place_medal" + DELIM: "🥇", DELIM + r"2nd_place_medal" + DELIM: "🥈", DELIM + r"3rd_place_medal" + DELIM: "🥉", # # Sport # DELIM + r"soccer" + DELIM: "⚽", DELIM + r"baseball" + DELIM: "⚾", DELIM + r"softball" + DELIM: "🥎", DELIM + r"basketball" + DELIM: "🏀", DELIM + r"volleyball" + DELIM: "🏐", DELIM + r"football" + DELIM: "🏈", DELIM + r"rugby_football" + DELIM: "🏉", DELIM + r"tennis" + DELIM: "🎾", DELIM + r"flying_disc" + DELIM: "🥏", DELIM + r"bowling" + DELIM: "🎳", DELIM + r"cricket_game" + DELIM: "🏏", DELIM + r"field_hockey" + DELIM: "🏑", DELIM + r"ice_hockey" + DELIM: "🏒", DELIM + r"lacrosse" + DELIM: "🥍", DELIM + r"ping_pong" + DELIM: "🏓", DELIM + r"badminton" + DELIM: "🏸", DELIM + r"boxing_glove" + DELIM: "🥊", DELIM + r"martial_arts_uniform" + DELIM: "🥋", DELIM + r"goal_net" + DELIM: "🥅", DELIM + r"golf" + DELIM: "⛳", DELIM + r"ice_skate" + DELIM: "⛸️", DELIM + r"fishing_pole_and_fish" + DELIM: "🎣", DELIM + r"diving_mask" + DELIM: "🤿", DELIM + r"running_shirt_with_sash" + DELIM: "🎽", DELIM + r"ski" + DELIM: "🎿", DELIM + r"sled" + DELIM: "🛷", DELIM + r"curling_stone" + DELIM: "🥌", # # Game # DELIM + r"dart" + DELIM: "🎯", DELIM + r"yo_yo" + DELIM: "🪀", DELIM + r"kite" + DELIM: "🪁", DELIM + r"gun" + DELIM: "🔫", DELIM + r"8ball" + DELIM: "🎱", DELIM + r"crystal_ball" + DELIM: "🔮", DELIM + r"magic_wand" + DELIM: "🪄", DELIM + r"video_game" + DELIM: "🎮", DELIM + r"joystick" + DELIM: "🕹️", DELIM + r"slot_machine" + DELIM: "🎰", DELIM + r"game_die" + DELIM: "🎲", DELIM + r"jigsaw" + DELIM: "🧩", DELIM + r"teddy_bear" + DELIM: "🧸", DELIM + r"pinata" + DELIM: "🪅", DELIM + r"nesting_dolls" + DELIM: "🪆", DELIM + r"spades" + DELIM: "♠️", DELIM + r"hearts" + DELIM: "♥️", DELIM + r"diamonds" + DELIM: "♦️", DELIM + r"clubs" + DELIM: "♣️", DELIM + r"chess_pawn" + DELIM: "♟️", DELIM + r"black_joker" + DELIM: "🃏", DELIM + r"mahjong" + DELIM: "🀄", DELIM + r"flower_playing_cards" + DELIM: "🎴", # # Arts & Crafts # DELIM + r"performing_arts" + DELIM: "🎭", DELIM + r"framed_picture" + DELIM: "🖼️", DELIM + r"art" + DELIM: "🎨", DELIM + r"thread" + DELIM: "🧵", DELIM + r"sewing_needle" + DELIM: "🪡", DELIM + r"yarn" + DELIM: "🧶", DELIM + r"knot" + DELIM: "🪢", # # Clothing # DELIM + r"eyeglasses" + DELIM: "👓", DELIM + r"dark_sunglasses" + DELIM: "🕶️", DELIM + r"goggles" + DELIM: "🥽", DELIM + r"lab_coat" + DELIM: "🥼", DELIM + r"safety_vest" + DELIM: "🦺", DELIM + r"necktie" + DELIM: "👔", DELIM + r"t?shirt" + DELIM: "👕", DELIM + r"jeans" + DELIM: "👖", DELIM + r"scarf" + DELIM: "🧣", DELIM + r"gloves" + DELIM: "🧤", DELIM + r"coat" + DELIM: "🧥", DELIM + r"socks" + DELIM: "🧦", DELIM + r"dress" + DELIM: "👗", DELIM + r"kimono" + DELIM: "👘", DELIM + r"sari" + DELIM: "🥻", DELIM + r"one_piece_swimsuit" + DELIM: "🩱", DELIM + r"swim_brief" + DELIM: "🩲", DELIM + r"shorts" + DELIM: "🩳", DELIM + r"bikini" + DELIM: "👙", DELIM + r"womans_clothes" + DELIM: "👚", DELIM + r"purse" + DELIM: "👛", DELIM + r"handbag" + DELIM: "👜", DELIM + r"pouch" + DELIM: "👝", DELIM + r"shopping" + DELIM: "🛍️", DELIM + r"school_satchel" + DELIM: "🎒", DELIM + r"thong_sandal" + DELIM: "🩴", DELIM + r"(mans_)?shoe" + DELIM: "👞", DELIM + r"athletic_shoe" + DELIM: "👟", DELIM + r"hiking_boot" + DELIM: "🥾", DELIM + r"flat_shoe" + DELIM: "🥿", DELIM + r"high_heel" + DELIM: "👠", DELIM + r"sandal" + DELIM: "👡", DELIM + r"ballet_shoes" + DELIM: "🩰", DELIM + r"boot" + DELIM: "👢", DELIM + r"crown" + DELIM: "👑", DELIM + r"womans_hat" + DELIM: "👒", DELIM + r"tophat" + DELIM: "🎩", DELIM + r"mortar_board" + DELIM: "🎓", DELIM + r"billed_cap" + DELIM: "🧢", DELIM + r"military_helmet" + DELIM: "🪖", DELIM + r"rescue_worker_helmet" + DELIM: "⛑️", DELIM + r"prayer_beads" + DELIM: "📿", DELIM + r"lipstick" + DELIM: "💄", DELIM + r"ring" + DELIM: "💍", DELIM + r"gem" + DELIM: "💎", # # Sound # DELIM + r"mute" + DELIM: "🔇", DELIM + r"speaker" + DELIM: "🔈", DELIM + r"sound" + DELIM: "🔉", DELIM + r"loud_sound" + DELIM: "🔊", DELIM + r"loudspeaker" + DELIM: "📢", DELIM + r"mega" + DELIM: "📣", DELIM + r"postal_horn" + DELIM: "📯", DELIM + r"bell" + DELIM: "🔔", DELIM + r"no_bell" + DELIM: "🔕", # # Music # DELIM + r"musical_score" + DELIM: "🎼", DELIM + r"musical_note" + DELIM: "🎵", DELIM + r"notes" + DELIM: "🎶", DELIM + r"studio_microphone" + DELIM: "🎙️", DELIM + r"level_slider" + DELIM: "🎚️", DELIM + r"control_knobs" + DELIM: "🎛️", DELIM + r"microphone" + DELIM: "🎤", DELIM + r"headphones" + DELIM: "🎧", DELIM + r"radio" + DELIM: "📻", # # Musical Instrument # DELIM + r"saxophone" + DELIM: "🎷", DELIM + r"accordion" + DELIM: "🪗", DELIM + r"guitar" + DELIM: "🎸", DELIM + r"musical_keyboard" + DELIM: "🎹", DELIM + r"trumpet" + DELIM: "🎺", DELIM + r"violin" + DELIM: "🎻", DELIM + r"banjo" + DELIM: "🪕", DELIM + r"drum" + DELIM: "🥁", DELIM + r"long_drum" + DELIM: "🪘", # # Phone # DELIM + r"iphone" + DELIM: "📱", DELIM + r"calling" + DELIM: "📲", DELIM + r"phone" + DELIM: "☎️", DELIM + r"telephone(_receiver)?" + DELIM: "📞", DELIM + r"pager" + DELIM: "📟", DELIM + r"fax" + DELIM: "📠", # # Computer # DELIM + r"battery" + DELIM: "🔋", DELIM + r"electric_plug" + DELIM: "🔌", DELIM + r"computer" + DELIM: "💻", DELIM + r"desktop_computer" + DELIM: "🖥️", DELIM + r"printer" + DELIM: "🖨️", DELIM + r"keyboard" + DELIM: "⌨️", DELIM + r"computer_mouse" + DELIM: "🖱️", DELIM + r"trackball" + DELIM: "🖲️", DELIM + r"minidisc" + DELIM: "💽", DELIM + r"floppy_disk" + DELIM: "💾", DELIM + r"cd" + DELIM: "💿", DELIM + r"dvd" + DELIM: "📀", DELIM + r"abacus" + DELIM: "🧮", # # Light & Video # DELIM + r"movie_camera" + DELIM: "🎥", DELIM + r"film_strip" + DELIM: "🎞️", DELIM + r"film_projector" + DELIM: "📽️", DELIM + r"clapper" + DELIM: "🎬", DELIM + r"tv" + DELIM: "📺", DELIM + r"camera" + DELIM: "📷", DELIM + r"camera_flash" + DELIM: "📸", DELIM + r"video_camera" + DELIM: "📹", DELIM + r"vhs" + DELIM: "📼", DELIM + r"mag" + DELIM: "🔍", DELIM + r"mag_right" + DELIM: "🔎", DELIM + r"candle" + DELIM: "🕯️", DELIM + r"bulb" + DELIM: "💡", DELIM + r"flashlight" + DELIM: "🔦", DELIM + r"(izakaya_)?lantern" + DELIM: "🏮", DELIM + r"diya_lamp" + DELIM: "🪔", # # Book Paper # DELIM + r"notebook_with_decorative_cover" + DELIM: "📔", DELIM + r"closed_book" + DELIM: "📕", DELIM + r"(open_)?book" + DELIM: "📖", DELIM + r"green_book" + DELIM: "📗", DELIM + r"blue_book" + DELIM: "📘", DELIM + r"orange_book" + DELIM: "📙", DELIM + r"books" + DELIM: "📚", DELIM + r"notebook" + DELIM: "📓", DELIM + r"ledger" + DELIM: "📒", DELIM + r"page_with_curl" + DELIM: "📃", DELIM + r"scroll" + DELIM: "📜", DELIM + r"page_facing_up" + DELIM: "📄", DELIM + r"newspaper" + DELIM: "📰", DELIM + r"newspaper_roll" + DELIM: "🗞️", DELIM + r"bookmark_tabs" + DELIM: "📑", DELIM + r"bookmark" + DELIM: "🔖", DELIM + r"label" + DELIM: "🏷️", # # Money # DELIM + r"moneybag" + DELIM: "💰", DELIM + r"coin" + DELIM: "🪙", DELIM + r"yen" + DELIM: "💴", DELIM + r"dollar" + DELIM: "💵", DELIM + r"euro" + DELIM: "💶", DELIM + r"pound" + DELIM: "💷", DELIM + r"money_with_wings" + DELIM: "💸", DELIM + r"credit_card" + DELIM: "💳", DELIM + r"receipt" + DELIM: "🧾", DELIM + r"chart" + DELIM: "💹", # # Mail # DELIM + r"envelope" + DELIM: "✉️", DELIM + r"e-?mail" + DELIM: "📧", DELIM + r"incoming_envelope" + DELIM: "📨", DELIM + r"envelope_with_arrow" + DELIM: "📩", DELIM + r"outbox_tray" + DELIM: "📤", DELIM + r"inbox_tray" + DELIM: "📥", DELIM + r"package" + DELIM: "📦", DELIM + r"mailbox" + DELIM: "📫", DELIM + r"mailbox_closed" + DELIM: "📪", DELIM + r"mailbox_with_mail" + DELIM: "📬", DELIM + r"mailbox_with_no_mail" + DELIM: "📭", DELIM + r"postbox" + DELIM: "📮", DELIM + r"ballot_box" + DELIM: "🗳️", # # Writing # DELIM + r"pencil2" + DELIM: "✏️", DELIM + r"black_nib" + DELIM: "✒️", DELIM + r"fountain_pen" + DELIM: "🖋️", DELIM + r"pen" + DELIM: "🖊️", DELIM + r"paintbrush" + DELIM: "🖌️", DELIM + r"crayon" + DELIM: "🖍️", DELIM + r"(memo|pencil)" + DELIM: "📝", # # Office # DELIM + r"briefcase" + DELIM: "💼", DELIM + r"file_folder" + DELIM: "📁", DELIM + r"open_file_folder" + DELIM: "📂", DELIM + r"card_index_dividers" + DELIM: "🗂️", DELIM + r"date" + DELIM: "📅", DELIM + r"calendar" + DELIM: "📆", DELIM + r"spiral_notepad" + DELIM: "🗒️", DELIM + r"spiral_calendar" + DELIM: "🗓️", DELIM + r"card_index" + DELIM: "📇", DELIM + r"chart_with_upwards_trend" + DELIM: "📈", DELIM + r"chart_with_downwards_trend" + DELIM: "📉", DELIM + r"bar_chart" + DELIM: "📊", DELIM + r"clipboard" + DELIM: "📋", DELIM + r"pushpin" + DELIM: "📌", DELIM + r"round_pushpin" + DELIM: "📍", DELIM + r"paperclip" + DELIM: "📎", DELIM + r"paperclips" + DELIM: "🖇️", DELIM + r"straight_ruler" + DELIM: "📏", DELIM + r"triangular_ruler" + DELIM: "📐", DELIM + r"scissors" + DELIM: "✂️", DELIM + r"card_file_box" + DELIM: "🗃️", DELIM + r"file_cabinet" + DELIM: "🗄️", DELIM + r"wastebasket" + DELIM: "🗑️", # # Lock # DELIM + r"lock" + DELIM: "🔒", DELIM + r"unlock" + DELIM: "🔓", DELIM + r"lock_with_ink_pen" + DELIM: "🔏", DELIM + r"closed_lock_with_key" + DELIM: "🔐", DELIM + r"key" + DELIM: "🔑", DELIM + r"old_key" + DELIM: "🗝️", # # Tool # DELIM + r"hammer" + DELIM: "🔨", DELIM + r"axe" + DELIM: "🪓", DELIM + r"pick" + DELIM: "⛏️", DELIM + r"hammer_and_pick" + DELIM: "⚒️", DELIM + r"hammer_and_wrench" + DELIM: "🛠️", DELIM + r"dagger" + DELIM: "🗡️", DELIM + r"crossed_swords" + DELIM: "⚔️", DELIM + r"bomb" + DELIM: "💣", DELIM + r"boomerang" + DELIM: "🪃", DELIM + r"bow_and_arrow" + DELIM: "🏹", DELIM + r"shield" + DELIM: "🛡️", DELIM + r"carpentry_saw" + DELIM: "🪚", DELIM + r"wrench" + DELIM: "🔧", DELIM + r"screwdriver" + DELIM: "🪛", DELIM + r"nut_and_bolt" + DELIM: "🔩", DELIM + r"gear" + DELIM: "⚙️", DELIM + r"clamp" + DELIM: "🗜️", DELIM + r"balance_scale" + DELIM: "⚖️", DELIM + r"probing_cane" + DELIM: "🦯", DELIM + r"link" + DELIM: "🔗", DELIM + r"chains" + DELIM: "⛓️", DELIM + r"hook" + DELIM: "🪝", DELIM + r"toolbox" + DELIM: "🧰", DELIM + r"magnet" + DELIM: "🧲", DELIM + r"ladder" + DELIM: "🪜", # # Science # DELIM + r"alembic" + DELIM: "⚗️", DELIM + r"test_tube" + DELIM: "🧪", DELIM + r"petri_dish" + DELIM: "🧫", DELIM + r"dna" + DELIM: "🧬", DELIM + r"microscope" + DELIM: "🔬", DELIM + r"telescope" + DELIM: "🔭", DELIM + r"satellite" + DELIM: "📡", # # Medical # DELIM + r"syringe" + DELIM: "💉", DELIM + r"drop_of_blood" + DELIM: "🩸", DELIM + r"pill" + DELIM: "💊", DELIM + r"adhesive_bandage" + DELIM: "🩹", DELIM + r"stethoscope" + DELIM: "🩺", # # Household # DELIM + r"door" + DELIM: "🚪", DELIM + r"elevator" + DELIM: "🛗", DELIM + r"mirror" + DELIM: "🪞", DELIM + r"window" + DELIM: "🪟", DELIM + r"bed" + DELIM: "🛏️", DELIM + r"couch_and_lamp" + DELIM: "🛋️", DELIM + r"chair" + DELIM: "🪑", DELIM + r"toilet" + DELIM: "🚽", DELIM + r"plunger" + DELIM: "🪠", DELIM + r"shower" + DELIM: "🚿", DELIM + r"bathtub" + DELIM: "🛁", DELIM + r"mouse_trap" + DELIM: "🪤", DELIM + r"razor" + DELIM: "🪒", DELIM + r"lotion_bottle" + DELIM: "🧴", DELIM + r"safety_pin" + DELIM: "🧷", DELIM + r"broom" + DELIM: "🧹", DELIM + r"basket" + DELIM: "🧺", DELIM + r"roll_of_paper" + DELIM: "🧻", DELIM + r"bucket" + DELIM: "🪣", DELIM + r"soap" + DELIM: "🧼", DELIM + r"toothbrush" + DELIM: "🪥", DELIM + r"sponge" + DELIM: "🧽", DELIM + r"fire_extinguisher" + DELIM: "🧯", DELIM + r"shopping_cart" + DELIM: "🛒", # # Other Object # DELIM + r"smoking" + DELIM: "🚬", DELIM + r"coffin" + DELIM: "⚰️", DELIM + r"headstone" + DELIM: "🪦", DELIM + r"funeral_urn" + DELIM: "⚱️", DELIM + r"nazar_amulet" + DELIM: "🧿", DELIM + r"moyai" + DELIM: "🗿", DELIM + r"placard" + DELIM: "🪧", # # Transport Sign # DELIM + r"atm" + DELIM: "🏧", DELIM + r"put_litter_in_its_place" + DELIM: "🚮", DELIM + r"potable_water" + DELIM: "🚰", DELIM + r"wheelchair" + DELIM: "♿", DELIM + r"mens" + DELIM: "🚹", DELIM + r"womens" + DELIM: "🚺", DELIM + r"restroom" + DELIM: "🚻", DELIM + r"baby_symbol" + DELIM: "🚼", DELIM + r"wc" + DELIM: "🚾", DELIM + r"passport_control" + DELIM: "🛂", DELIM + r"customs" + DELIM: "🛃", DELIM + r"baggage_claim" + DELIM: "🛄", DELIM + r"left_luggage" + DELIM: "🛅", # # Warning # DELIM + r"warning" + DELIM: "⚠️", DELIM + r"children_crossing" + DELIM: "🚸", DELIM + r"no_entry" + DELIM: "⛔", DELIM + r"no_entry_sign" + DELIM: "🚫", DELIM + r"no_bicycles" + DELIM: "🚳", DELIM + r"no_smoking" + DELIM: "🚭", DELIM + r"do_not_litter" + DELIM: "🚯", DELIM + r"non-potable_water" + DELIM: "🚱", DELIM + r"no_pedestrians" + DELIM: "🚷", DELIM + r"no_mobile_phones" + DELIM: "📵", DELIM + r"underage" + DELIM: "🔞", DELIM + r"radioactive" + DELIM: "☢️", DELIM + r"biohazard" + DELIM: "☣️", # # Arrow # DELIM + r"arrow_up" + DELIM: "⬆️", DELIM + r"arrow_upper_right" + DELIM: "↗️", DELIM + r"arrow_right" + DELIM: "➡️", DELIM + r"arrow_lower_right" + DELIM: "↘️", DELIM + r"arrow_down" + DELIM: "⬇️", DELIM + r"arrow_lower_left" + DELIM: "↙️", DELIM + r"arrow_left" + DELIM: "⬅️", DELIM + r"arrow_upper_left" + DELIM: "↖️", DELIM + r"arrow_up_down" + DELIM: "↕️", DELIM + r"left_right_arrow" + DELIM: "↔️", DELIM + r"leftwards_arrow_with_hook" + DELIM: "↩️", DELIM + r"arrow_right_hook" + DELIM: "↪️", DELIM + r"arrow_heading_up" + DELIM: "⤴️", DELIM + r"arrow_heading_down" + DELIM: "⤵️", DELIM + r"arrows_clockwise" + DELIM: "🔃", DELIM + r"arrows_counterclockwise" + DELIM: "🔄", DELIM + r"back" + DELIM: "🔙", DELIM + r"end" + DELIM: "🔚", DELIM + r"on" + DELIM: "🔛", DELIM + r"soon" + DELIM: "🔜", DELIM + r"top" + DELIM: "🔝", # # Religion # DELIM + r"place_of_worship" + DELIM: "🛐", DELIM + r"atom_symbol" + DELIM: "⚛️", DELIM + r"om" + DELIM: "🕉️", DELIM + r"star_of_david" + DELIM: "✡️", DELIM + r"wheel_of_dharma" + DELIM: "☸️", DELIM + r"yin_yang" + DELIM: "☯️", DELIM + r"latin_cross" + DELIM: "✝️", DELIM + r"orthodox_cross" + DELIM: "☦️", DELIM + r"star_and_crescent" + DELIM: "☪️", DELIM + r"peace_symbol" + DELIM: "☮️", DELIM + r"menorah" + DELIM: "🕎", DELIM + r"six_pointed_star" + DELIM: "🔯", # # Zodiac # DELIM + r"aries" + DELIM: "♈", DELIM + r"taurus" + DELIM: "♉", DELIM + r"gemini" + DELIM: "♊", DELIM + r"cancer" + DELIM: "♋", DELIM + r"leo" + DELIM: "♌", DELIM + r"virgo" + DELIM: "♍", DELIM + r"libra" + DELIM: "♎", DELIM + r"scorpius" + DELIM: "♏", DELIM + r"sagittarius" + DELIM: "♐", DELIM + r"capricorn" + DELIM: "♑", DELIM + r"aquarius" + DELIM: "♒", DELIM + r"pisces" + DELIM: "♓", DELIM + r"ophiuchus" + DELIM: "⛎", # # Av Symbol # DELIM + r"twisted_rightwards_arrows" + DELIM: "🔀", DELIM + r"repeat" + DELIM: "🔁", DELIM + r"repeat_one" + DELIM: "🔂", DELIM + r"arrow_forward" + DELIM: "▶️", DELIM + r"fast_forward" + DELIM: "⏩", DELIM + r"next_track_button" + DELIM: "⏭️", DELIM + r"play_or_pause_button" + DELIM: "⏯️", DELIM + r"arrow_backward" + DELIM: "◀️", DELIM + r"rewind" + DELIM: "⏪", DELIM + r"previous_track_button" + DELIM: "⏮️", DELIM + r"arrow_up_small" + DELIM: "🔼", DELIM + r"arrow_double_up" + DELIM: "⏫", DELIM + r"arrow_down_small" + DELIM: "🔽", DELIM + r"arrow_double_down" + DELIM: "⏬", DELIM + r"pause_button" + DELIM: "⏸️", DELIM + r"stop_button" + DELIM: "⏹️", DELIM + r"record_button" + DELIM: "⏺️", DELIM + r"eject_button" + DELIM: "⏏️", DELIM + r"cinema" + DELIM: "🎦", DELIM + r"low_brightness" + DELIM: "🔅", DELIM + r"high_brightness" + DELIM: "🔆", DELIM + r"signal_strength" + DELIM: "📶", DELIM + r"vibration_mode" + DELIM: "📳", DELIM + r"mobile_phone_off" + DELIM: "📴", # # Gender # DELIM + r"female_sign" + DELIM: "♀️", DELIM + r"male_sign" + DELIM: "♂️", DELIM + r"transgender_symbol" + DELIM: "⚧️", # # Math # DELIM + r"heavy_multiplication_x" + DELIM: "✖️", DELIM + r"heavy_plus_sign" + DELIM: "➕", # noqa: RUF001 DELIM + r"heavy_minus_sign" + DELIM: "➖", # noqa: RUF001 DELIM + r"heavy_division_sign" + DELIM: "➗", DELIM + r"infinity" + DELIM: "♾️", # # Punctuation # DELIM + r"bangbang" + DELIM: "‼️", DELIM + r"interrobang" + DELIM: "⁉️", DELIM + r"question" + DELIM: "❓", DELIM + r"grey_question" + DELIM: "❔", DELIM + r"grey_exclamation" + DELIM: "❕", DELIM + r"(heavy_exclamation_mark|exclamation)" + DELIM: "❗", DELIM + r"wavy_dash" + DELIM: "〰️", # # Currency # DELIM + r"currency_exchange" + DELIM: "💱", DELIM + r"heavy_dollar_sign" + DELIM: "💲", # # Other Symbol # DELIM + r"medical_symbol" + DELIM: "⚕️", DELIM + r"recycle" + DELIM: "♻️", DELIM + r"fleur_de_lis" + DELIM: "⚜️", DELIM + r"trident" + DELIM: "🔱", DELIM + r"name_badge" + DELIM: "📛", DELIM + r"beginner" + DELIM: "🔰", DELIM + r"o" + DELIM: "⭕", DELIM + r"white_check_mark" + DELIM: "✅", DELIM + r"ballot_box_with_check" + DELIM: "☑️", DELIM + r"heavy_check_mark" + DELIM: "✔️", DELIM + r"x" + DELIM: "❌", DELIM + r"negative_squared_cross_mark" + DELIM: "❎", DELIM + r"curly_loop" + DELIM: "➰", DELIM + r"loop" + DELIM: "➿", DELIM + r"part_alternation_mark" + DELIM: "〽️", DELIM + r"eight_spoked_asterisk" + DELIM: "✳️", DELIM + r"eight_pointed_black_star" + DELIM: "✴️", DELIM + r"sparkle" + DELIM: "❇️", DELIM + r"copyright" + DELIM: "©️", DELIM + r"registered" + DELIM: "®️", DELIM + r"tm" + DELIM: "™️", # # Keycap # DELIM + r"hash" + DELIM: "#️⃣", DELIM + r"asterisk" + DELIM: "*️⃣", DELIM + r"zero" + DELIM: "0️⃣", DELIM + r"one" + DELIM: "1️⃣", DELIM + r"two" + DELIM: "2️⃣", DELIM + r"three" + DELIM: "3️⃣", DELIM + r"four" + DELIM: "4️⃣", DELIM + r"five" + DELIM: "5️⃣", DELIM + r"six" + DELIM: "6️⃣", DELIM + r"seven" + DELIM: "7️⃣", DELIM + r"eight" + DELIM: "8️⃣", DELIM + r"nine" + DELIM: "9️⃣", DELIM + r"keycap_ten" + DELIM: "🔟", # # Alphanum # DELIM + r"capital_abcd" + DELIM: "🔠", DELIM + r"abcd" + DELIM: "🔡", DELIM + r"1234" + DELIM: "🔢", DELIM + r"symbols" + DELIM: "🔣", DELIM + r"abc" + DELIM: "🔤", DELIM + r"a" + DELIM: "🅰️", DELIM + r"ab" + DELIM: "🆎", DELIM + r"b" + DELIM: "🅱️", DELIM + r"cl" + DELIM: "🆑", DELIM + r"cool" + DELIM: "🆒", DELIM + r"free" + DELIM: "🆓", DELIM + r"information_source" + DELIM: "ℹ️", # noqa: RUF001 DELIM + r"id" + DELIM: "🆔", DELIM + r"m" + DELIM: "Ⓜ️", DELIM + r"new" + DELIM: "🆕", DELIM + r"ng" + DELIM: "🆖", DELIM + r"o2" + DELIM: "🅾️", DELIM + r"ok" + DELIM: "🆗", DELIM + r"parking" + DELIM: "🅿️", DELIM + r"sos" + DELIM: "🆘", DELIM + r"up" + DELIM: "🆙", DELIM + r"vs" + DELIM: "🆚", DELIM + r"koko" + DELIM: "🈁", DELIM + r"sa" + DELIM: "🈂️", DELIM + r"u6708" + DELIM: "🈷️", DELIM + r"u6709" + DELIM: "🈶", DELIM + r"u6307" + DELIM: "🈯", DELIM + r"ideograph_advantage" + DELIM: "🉐", DELIM + r"u5272" + DELIM: "🈹", DELIM + r"u7121" + DELIM: "🈚", DELIM + r"u7981" + DELIM: "🈲", DELIM + r"accept" + DELIM: "🉑", DELIM + r"u7533" + DELIM: "🈸", DELIM + r"u5408" + DELIM: "🈴", DELIM + r"u7a7a" + DELIM: "🈳", DELIM + r"congratulations" + DELIM: "㊗️", DELIM + r"secret" + DELIM: "㊙️", DELIM + r"u55b6" + DELIM: "🈺", DELIM + r"u6e80" + DELIM: "🈵", # # Geometric # DELIM + r"red_circle" + DELIM: "🔴", DELIM + r"orange_circle" + DELIM: "🟠", DELIM + r"yellow_circle" + DELIM: "🟡", DELIM + r"green_circle" + DELIM: "🟢", DELIM + r"large_blue_circle" + DELIM: "🔵", DELIM + r"purple_circle" + DELIM: "🟣", DELIM + r"brown_circle" + DELIM: "🟤", DELIM + r"black_circle" + DELIM: "⚫", DELIM + r"white_circle" + DELIM: "⚪", DELIM + r"red_square" + DELIM: "🟥", DELIM + r"orange_square" + DELIM: "🟧", DELIM + r"yellow_square" + DELIM: "🟨", DELIM + r"green_square" + DELIM: "🟩", DELIM + r"blue_square" + DELIM: "🟦", DELIM + r"purple_square" + DELIM: "🟪", DELIM + r"brown_square" + DELIM: "🟫", DELIM + r"black_large_square" + DELIM: "⬛", DELIM + r"white_large_square" + DELIM: "⬜", DELIM + r"black_medium_square" + DELIM: "◼️", DELIM + r"white_medium_square" + DELIM: "◻️", DELIM + r"black_medium_small_square" + DELIM: "◾", DELIM + r"white_medium_small_square" + DELIM: "◽", DELIM + r"black_small_square" + DELIM: "▪️", DELIM + r"white_small_square" + DELIM: "▫️", DELIM + r"large_orange_diamond" + DELIM: "🔶", DELIM + r"large_blue_diamond" + DELIM: "🔷", DELIM + r"small_orange_diamond" + DELIM: "🔸", DELIM + r"small_blue_diamond" + DELIM: "🔹", DELIM + r"small_red_triangle" + DELIM: "🔺", DELIM + r"small_red_triangle_down" + DELIM: "🔻", DELIM + r"diamond_shape_with_a_dot_inside" + DELIM: "💠", DELIM + r"radio_button" + DELIM: "🔘", DELIM + r"white_square_button" + DELIM: "🔳", DELIM + r"black_square_button" + DELIM: "🔲", # # Flag # DELIM + r"checkered_flag" + DELIM: "🏁", DELIM + r"triangular_flag_on_post" + DELIM: "🚩", DELIM + r"crossed_flags" + DELIM: "🎌", DELIM + r"black_flag" + DELIM: "🏴", DELIM + r"white_flag" + DELIM: "🏳️", DELIM + r"rainbow_flag" + DELIM: "🏳️‍🌈", DELIM + r"transgender_flag" + DELIM: "🏳️‍⚧️", DELIM + r"pirate_flag" + DELIM: "🏴‍☠️", # # Country Flag # DELIM + r"ascension_island" + DELIM: "🇦🇨", DELIM + r"andorra" + DELIM: "🇦🇩", DELIM + r"united_arab_emirates" + DELIM: "🇦🇪", DELIM + r"afghanistan" + DELIM: "🇦🇫", DELIM + r"antigua_barbuda" + DELIM: "🇦🇬", DELIM + r"anguilla" + DELIM: "🇦🇮", DELIM + r"albania" + DELIM: "🇦🇱", DELIM + r"armenia" + DELIM: "🇦🇲", DELIM + r"angola" + DELIM: "🇦🇴", DELIM + r"antarctica" + DELIM: "🇦🇶", DELIM + r"argentina" + DELIM: "🇦🇷", DELIM + r"american_samoa" + DELIM: "🇦🇸", DELIM + r"austria" + DELIM: "🇦🇹", DELIM + r"australia" + DELIM: "🇦🇺", DELIM + r"aruba" + DELIM: "🇦🇼", DELIM + r"aland_islands" + DELIM: "🇦🇽", DELIM + r"azerbaijan" + DELIM: "🇦🇿", DELIM + r"bosnia_herzegovina" + DELIM: "🇧🇦", DELIM + r"barbados" + DELIM: "🇧🇧", DELIM + r"bangladesh" + DELIM: "🇧🇩", DELIM + r"belgium" + DELIM: "🇧🇪", DELIM + r"burkina_faso" + DELIM: "🇧🇫", DELIM + r"bulgaria" + DELIM: "🇧🇬", DELIM + r"bahrain" + DELIM: "🇧🇭", DELIM + r"burundi" + DELIM: "🇧🇮", DELIM + r"benin" + DELIM: "🇧🇯", DELIM + r"st_barthelemy" + DELIM: "🇧🇱", DELIM + r"bermuda" + DELIM: "🇧🇲", DELIM + r"brunei" + DELIM: "🇧🇳", DELIM + r"bolivia" + DELIM: "🇧🇴", DELIM + r"caribbean_netherlands" + DELIM: "🇧🇶", DELIM + r"brazil" + DELIM: "🇧🇷", DELIM + r"bahamas" + DELIM: "🇧🇸", DELIM + r"bhutan" + DELIM: "🇧🇹", DELIM + r"bouvet_island" + DELIM: "🇧🇻", DELIM + r"botswana" + DELIM: "🇧🇼", DELIM + r"belarus" + DELIM: "🇧🇾", DELIM + r"belize" + DELIM: "🇧🇿", DELIM + r"canada" + DELIM: "🇨🇦", DELIM + r"cocos_islands" + DELIM: "🇨🇨", DELIM + r"congo_kinshasa" + DELIM: "🇨🇩", DELIM + r"central_african_republic" + DELIM: "🇨🇫", DELIM + r"congo_brazzaville" + DELIM: "🇨🇬", DELIM + r"switzerland" + DELIM: "🇨🇭", DELIM + r"cote_divoire" + DELIM: "🇨🇮", DELIM + r"cook_islands" + DELIM: "🇨🇰", DELIM + r"chile" + DELIM: "🇨🇱", DELIM + r"cameroon" + DELIM: "🇨🇲", DELIM + r"cn" + DELIM: "🇨🇳", DELIM + r"colombia" + DELIM: "🇨🇴", DELIM + r"clipperton_island" + DELIM: "🇨🇵", DELIM + r"costa_rica" + DELIM: "🇨🇷", DELIM + r"cuba" + DELIM: "🇨🇺", DELIM + r"cape_verde" + DELIM: "🇨🇻", DELIM + r"curacao" + DELIM: "🇨🇼", DELIM + r"christmas_island" + DELIM: "🇨🇽", DELIM + r"cyprus" + DELIM: "🇨🇾", DELIM + r"czech_republic" + DELIM: "🇨🇿", DELIM + r"de" + DELIM: "🇩🇪", DELIM + r"diego_garcia" + DELIM: "🇩🇬", DELIM + r"djibouti" + DELIM: "🇩🇯", DELIM + r"denmark" + DELIM: "🇩🇰", DELIM + r"dominica" + DELIM: "🇩🇲", DELIM + r"dominican_republic" + DELIM: "🇩🇴", DELIM + r"algeria" + DELIM: "🇩🇿", DELIM + r"ceuta_melilla" + DELIM: "🇪🇦", DELIM + r"ecuador" + DELIM: "🇪🇨", DELIM + r"estonia" + DELIM: "🇪🇪", DELIM + r"egypt" + DELIM: "🇪🇬", DELIM + r"western_sahara" + DELIM: "🇪🇭", DELIM + r"eritrea" + DELIM: "🇪🇷", DELIM + r"es" + DELIM: "🇪🇸", DELIM + r"ethiopia" + DELIM: "🇪🇹", DELIM + r"(eu|european_union)" + DELIM: "🇪🇺", DELIM + r"finland" + DELIM: "🇫🇮", DELIM + r"fiji" + DELIM: "🇫🇯", DELIM + r"falkland_islands" + DELIM: "🇫🇰", DELIM + r"micronesia" + DELIM: "🇫🇲", DELIM + r"faroe_islands" + DELIM: "🇫🇴", DELIM + r"fr" + DELIM: "🇫🇷", DELIM + r"gabon" + DELIM: "🇬🇦", DELIM + r"(uk|gb)" + DELIM: "🇬🇧", DELIM + r"grenada" + DELIM: "🇬🇩", DELIM + r"georgia" + DELIM: "🇬🇪", DELIM + r"french_guiana" + DELIM: "🇬🇫", DELIM + r"guernsey" + DELIM: "🇬🇬", DELIM + r"ghana" + DELIM: "🇬🇭", DELIM + r"gibraltar" + DELIM: "🇬🇮", DELIM + r"greenland" + DELIM: "🇬🇱", DELIM + r"gambia" + DELIM: "🇬🇲", DELIM + r"guinea" + DELIM: "🇬🇳", DELIM + r"guadeloupe" + DELIM: "🇬🇵", DELIM + r"equatorial_guinea" + DELIM: "🇬🇶", DELIM + r"greece" + DELIM: "🇬🇷", DELIM + r"south_georgia_south_sandwich_islands" + DELIM: "🇬🇸", DELIM + r"guatemala" + DELIM: "🇬🇹", DELIM + r"guam" + DELIM: "🇬🇺", DELIM + r"guinea_bissau" + DELIM: "🇬🇼", DELIM + r"guyana" + DELIM: "🇬🇾", DELIM + r"hong_kong" + DELIM: "🇭🇰", DELIM + r"heard_mcdonald_islands" + DELIM: "🇭🇲", DELIM + r"honduras" + DELIM: "🇭🇳", DELIM + r"croatia" + DELIM: "🇭🇷", DELIM + r"haiti" + DELIM: "🇭🇹", DELIM + r"hungary" + DELIM: "🇭🇺", DELIM + r"canary_islands" + DELIM: "🇮🇨", DELIM + r"indonesia" + DELIM: "🇮🇩", DELIM + r"ireland" + DELIM: "🇮🇪", DELIM + r"israel" + DELIM: "🇮🇱", DELIM + r"isle_of_man" + DELIM: "🇮🇲", DELIM + r"india" + DELIM: "🇮🇳", DELIM + r"british_indian_ocean_territory" + DELIM: "🇮🇴", DELIM + r"iraq" + DELIM: "🇮🇶", DELIM + r"iran" + DELIM: "🇮🇷", DELIM + r"iceland" + DELIM: "🇮🇸", DELIM + r"it" + DELIM: "🇮🇹", DELIM + r"jersey" + DELIM: "🇯🇪", DELIM + r"jamaica" + DELIM: "🇯🇲", DELIM + r"jordan" + DELIM: "🇯🇴", DELIM + r"jp" + DELIM: "🇯🇵", DELIM + r"kenya" + DELIM: "🇰🇪", DELIM + r"kyrgyzstan" + DELIM: "🇰🇬", DELIM + r"cambodia" + DELIM: "🇰🇭", DELIM + r"kiribati" + DELIM: "🇰🇮", DELIM + r"comoros" + DELIM: "🇰🇲", DELIM + r"st_kitts_nevis" + DELIM: "🇰🇳", DELIM + r"north_korea" + DELIM: "🇰🇵", DELIM + r"kr" + DELIM: "🇰🇷", DELIM + r"kuwait" + DELIM: "🇰🇼", DELIM + r"cayman_islands" + DELIM: "🇰🇾", DELIM + r"kazakhstan" + DELIM: "🇰🇿", DELIM + r"laos" + DELIM: "🇱🇦", DELIM + r"lebanon" + DELIM: "🇱🇧", DELIM + r"st_lucia" + DELIM: "🇱🇨", DELIM + r"liechtenstein" + DELIM: "🇱🇮", DELIM + r"sri_lanka" + DELIM: "🇱🇰", DELIM + r"liberia" + DELIM: "🇱🇷", DELIM + r"lesotho" + DELIM: "🇱🇸", DELIM + r"lithuania" + DELIM: "🇱🇹", DELIM + r"luxembourg" + DELIM: "🇱🇺", DELIM + r"latvia" + DELIM: "🇱🇻", DELIM + r"libya" + DELIM: "🇱🇾", DELIM + r"morocco" + DELIM: "🇲🇦", DELIM + r"monaco" + DELIM: "🇲🇨", DELIM + r"moldova" + DELIM: "🇲🇩", DELIM + r"montenegro" + DELIM: "🇲🇪", DELIM + r"st_martin" + DELIM: "🇲🇫", DELIM + r"madagascar" + DELIM: "🇲🇬", DELIM + r"marshall_islands" + DELIM: "🇲🇭", DELIM + r"macedonia" + DELIM: "🇲🇰", DELIM + r"mali" + DELIM: "🇲🇱", DELIM + r"myanmar" + DELIM: "🇲🇲", DELIM + r"mongolia" + DELIM: "🇲🇳", DELIM + r"macau" + DELIM: "🇲🇴", DELIM + r"northern_mariana_islands" + DELIM: "🇲🇵", DELIM + r"martinique" + DELIM: "🇲🇶", DELIM + r"mauritania" + DELIM: "🇲🇷", DELIM + r"montserrat" + DELIM: "🇲🇸", DELIM + r"malta" + DELIM: "🇲🇹", DELIM + r"mauritius" + DELIM: "🇲🇺", DELIM + r"maldives" + DELIM: "🇲🇻", DELIM + r"malawi" + DELIM: "🇲🇼", DELIM + r"mexico" + DELIM: "🇲🇽", DELIM + r"malaysia" + DELIM: "🇲🇾", DELIM + r"mozambique" + DELIM: "🇲🇿", DELIM + r"namibia" + DELIM: "🇳🇦", DELIM + r"new_caledonia" + DELIM: "🇳🇨", DELIM + r"niger" + DELIM: "🇳🇪", DELIM + r"norfolk_island" + DELIM: "🇳🇫", DELIM + r"nigeria" + DELIM: "🇳🇬", DELIM + r"nicaragua" + DELIM: "🇳🇮", DELIM + r"netherlands" + DELIM: "🇳🇱", DELIM + r"norway" + DELIM: "🇳🇴", DELIM + r"nepal" + DELIM: "🇳🇵", DELIM + r"nauru" + DELIM: "🇳🇷", DELIM + r"niue" + DELIM: "🇳🇺", DELIM + r"new_zealand" + DELIM: "🇳🇿", DELIM + r"oman" + DELIM: "🇴🇲", DELIM + r"panama" + DELIM: "🇵🇦", DELIM + r"peru" + DELIM: "🇵🇪", DELIM + r"french_polynesia" + DELIM: "🇵🇫", DELIM + r"papua_new_guinea" + DELIM: "🇵🇬", DELIM + r"philippines" + DELIM: "🇵🇭", DELIM + r"pakistan" + DELIM: "🇵🇰", DELIM + r"poland" + DELIM: "🇵🇱", DELIM + r"st_pierre_miquelon" + DELIM: "🇵🇲", DELIM + r"pitcairn_islands" + DELIM: "🇵🇳", DELIM + r"puerto_rico" + DELIM: "🇵🇷", DELIM + r"palestinian_territories" + DELIM: "🇵🇸", DELIM + r"portugal" + DELIM: "🇵🇹", DELIM + r"palau" + DELIM: "🇵🇼", DELIM + r"paraguay" + DELIM: "🇵🇾", DELIM + r"qatar" + DELIM: "🇶🇦", DELIM + r"reunion" + DELIM: "🇷🇪", DELIM + r"romania" + DELIM: "🇷🇴", DELIM + r"serbia" + DELIM: "🇷🇸", DELIM + r"ru" + DELIM: "🇷🇺", DELIM + r"rwanda" + DELIM: "🇷🇼", DELIM + r"saudi_arabia" + DELIM: "🇸🇦", DELIM + r"solomon_islands" + DELIM: "🇸🇧", DELIM + r"seychelles" + DELIM: "🇸🇨", DELIM + r"sudan" + DELIM: "🇸🇩", DELIM + r"sweden" + DELIM: "🇸🇪", DELIM + r"singapore" + DELIM: "🇸🇬", DELIM + r"st_helena" + DELIM: "🇸🇭", DELIM + r"slovenia" + DELIM: "🇸🇮", DELIM + r"svalbard_jan_mayen" + DELIM: "🇸🇯", DELIM + r"slovakia" + DELIM: "🇸🇰", DELIM + r"sierra_leone" + DELIM: "🇸🇱", DELIM + r"san_marino" + DELIM: "🇸🇲", DELIM + r"senegal" + DELIM: "🇸🇳", DELIM + r"somalia" + DELIM: "🇸🇴", DELIM + r"suriname" + DELIM: "🇸🇷", DELIM + r"south_sudan" + DELIM: "🇸🇸", DELIM + r"sao_tome_principe" + DELIM: "🇸🇹", DELIM + r"el_salvador" + DELIM: "🇸🇻", DELIM + r"sint_maarten" + DELIM: "🇸🇽", DELIM + r"syria" + DELIM: "🇸🇾", DELIM + r"swaziland" + DELIM: "🇸🇿", DELIM + r"tristan_da_cunha" + DELIM: "🇹🇦", DELIM + r"turks_caicos_islands" + DELIM: "🇹🇨", DELIM + r"chad" + DELIM: "🇹🇩", DELIM + r"french_southern_territories" + DELIM: "🇹🇫", DELIM + r"togo" + DELIM: "🇹🇬", DELIM + r"thailand" + DELIM: "🇹🇭", DELIM + r"tajikistan" + DELIM: "🇹🇯", DELIM + r"tokelau" + DELIM: "🇹🇰", DELIM + r"timor_leste" + DELIM: "🇹🇱", DELIM + r"turkmenistan" + DELIM: "🇹🇲", DELIM + r"tunisia" + DELIM: "🇹🇳", DELIM + r"tonga" + DELIM: "🇹🇴", DELIM + r"tr" + DELIM: "🇹🇷", DELIM + r"trinidad_tobago" + DELIM: "🇹🇹", DELIM + r"tuvalu" + DELIM: "🇹🇻", DELIM + r"taiwan" + DELIM: "🇹🇼", DELIM + r"tanzania" + DELIM: "🇹🇿", DELIM + r"ukraine" + DELIM: "🇺🇦", DELIM + r"uganda" + DELIM: "🇺🇬", DELIM + r"us_outlying_islands" + DELIM: "🇺🇲", DELIM + r"united_nations" + DELIM: "🇺🇳", DELIM + r"us" + DELIM: "🇺🇸", DELIM + r"uruguay" + DELIM: "🇺🇾", DELIM + r"uzbekistan" + DELIM: "🇺🇿", DELIM + r"vatican_city" + DELIM: "🇻🇦", DELIM + r"st_vincent_grenadines" + DELIM: "🇻🇨", DELIM + r"venezuela" + DELIM: "🇻🇪", DELIM + r"british_virgin_islands" + DELIM: "🇻🇬", DELIM + r"us_virgin_islands" + DELIM: "🇻🇮", DELIM + r"vietnam" + DELIM: "🇻🇳", DELIM + r"vanuatu" + DELIM: "🇻🇺", DELIM + r"wallis_futuna" + DELIM: "🇼🇫", DELIM + r"samoa" + DELIM: "🇼🇸", DELIM + r"kosovo" + DELIM: "🇽🇰", DELIM + r"yemen" + DELIM: "🇾🇪", DELIM + r"mayotte" + DELIM: "🇾🇹", DELIM + r"south_africa" + DELIM: "🇿🇦", DELIM + r"zambia" + DELIM: "🇿🇲", DELIM + r"zimbabwe" + DELIM: "🇿🇼", # # Subdivision Flag # DELIM + r"england" + DELIM: "🏴󠁧󠁢󠁥󠁮󠁧󠁿", DELIM + r"scotland" + DELIM: "🏴󠁧󠁢󠁳󠁣󠁴󠁿", DELIM + r"wales" + DELIM: "🏴󠁧󠁢󠁷󠁬󠁳󠁿", } # Define our singlton EMOJI_COMPILED_MAP = None def apply_emojis(content): """Takes the content and swaps any matched emoji's found with their utf-8 encoded mapping.""" global EMOJI_COMPILED_MAP if EMOJI_COMPILED_MAP is None: t_start = time.time() # Perform our compilation EMOJI_COMPILED_MAP = re.compile( r"(" + "|".join(EMOJI_MAP.keys()) + r")", re.IGNORECASE ) logger.trace(f"Emoji engine loaded in {time.time() - t_start:.4f}s") try: return EMOJI_COMPILED_MAP.sub(lambda x: EMOJI_MAP[x.group()], content) except TypeError: # No change; but force string return return "" ================================================ FILE: apprise/exception.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import errno class AppriseException(Exception): """Base Apprise Exception Class.""" def __init__(self, message, error_code=0): super().__init__(message) self.error_code = error_code class ApprisePluginException(AppriseException): """Class object for handling exceptions raised from within a plugin.""" def __init__(self, message, error_code=600): super().__init__(message, error_code=error_code) class AppriseDiskIOError(AppriseException): """Thrown when an disk i/o error occurs.""" def __init__(self, message, error_code=errno.EIO): super().__init__(message, error_code=error_code) class AppriseInvalidData(AppriseException): """Thrown when bad data was passed into an internal function.""" def __init__(self, message, error_code=errno.EINVAL): super().__init__(message, error_code=error_code) class AppriseFileNotFound(AppriseDiskIOError, FileNotFoundError): """Thrown when a persistent write occured in MEMORY mode.""" def __init__(self, message): super().__init__(message, error_code=errno.ENOENT) ================================================ FILE: apprise/i18n/__init__.py ================================================ ================================================ FILE: apprise/i18n/en/LC_MESSAGES/apprise.po ================================================ # English translations for apprise. # Copyright (C) 2026 Chris Caron # This file is distributed under the same license as the apprise project. # Chris Caron , 2026. # msgid "" msgstr "" "Project-Id-Version: apprise 1.9.8\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2026-03-08 16:43-0400\n" "PO-Revision-Date: 2019-05-24 20:00-0400\n" "Last-Translator: Chris Caron \n" "Language: en\n" "Language-Team: en \n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" #: apprise/attachment/base.py:96 apprise/url.py:141 msgid "Verify SSL" msgstr "Verify SSL" #: apprise/url.py:151 #, fuzzy msgid "Socket Read Timeout" msgstr "Server Timeout" #: apprise/url.py:165 #, fuzzy msgid "Socket Connect Timeout" msgstr "Server Timeout" #: apprise/attachment/base.py:82 msgid "Cache Age" msgstr "" #: apprise/attachment/base.py:88 msgid "Forced Mime Type" msgstr "" #: apprise/attachment/base.py:92 msgid "Forced File Name" msgstr "" #: apprise/attachment/file.py:41 apprise/config/file.py:41 msgid "Local File" msgstr "" #: apprise/attachment/http.py:46 apprise/config/http.py:54 msgid "Web Based" msgstr "" #: apprise/attachment/memory.py:44 apprise/config/memory.py:37 msgid "Memory" msgstr "" #: apprise/plugins/__init__.py:280 msgid "Schema" msgstr "Schema" #: apprise/plugins/__init__.py:401 msgid "No dependencies." msgstr "" #: apprise/plugins/__init__.py:404 msgid "Packages are required to function." msgstr "" #: apprise/plugins/__init__.py:408 msgid "Packages are recommended to improve functionality." msgstr "" #: apprise/plugins/africas_talking.py:132 #, fuzzy msgid "App User Name" msgstr "User Name" #: apprise/plugins/africas_talking.py:138 apprise/plugins/brevo.py:112 #: apprise/plugins/burstsms.py:104 apprise/plugins/clicksend.py:98 #: apprise/plugins/dot.py:121 apprise/plugins/fcm/__init__.py:143 #: apprise/plugins/httpsms.py:77 apprise/plugins/join.py:140 #: apprise/plugins/kavenegar.py:115 apprise/plugins/kumulos.py:87 #: apprise/plugins/mailgun.py:146 apprise/plugins/messagebird.py:78 #: apprise/plugins/one_signal.py:113 apprise/plugins/opsgenie.py:236 #: apprise/plugins/pagerduty.py:131 apprise/plugins/popcorn_notify.py:71 #: apprise/plugins/prowl.py:121 apprise/plugins/resend.py:107 #: apprise/plugins/sendgrid.py:116 apprise/plugins/seven.py:75 #: apprise/plugins/simplepush.py:101 apprise/plugins/smsmanager.py:106 #: apprise/plugins/smtp2go.py:118 apprise/plugins/sparkpost.py:169 #: apprise/plugins/splunk.py:165 apprise/plugins/techuluspush.py:97 #: apprise/plugins/twilio.py:197 apprise/plugins/vapid/__init__.py:152 #: apprise/plugins/vonage.py:80 msgid "API Key" msgstr "API Key" #: apprise/plugins/africas_talking.py:145 apprise/plugins/fortysixelks.py:106 #, fuzzy msgid "Target Phone" msgstr "Target Phone No" #: apprise/plugins/africas_talking.py:150 apprise/plugins/aprs.py:187 #: apprise/plugins/bark.py:159 apprise/plugins/brevo.py:129 #: apprise/plugins/bulksms.py:137 apprise/plugins/bulkvs.py:110 #: apprise/plugins/burstsms.py:131 apprise/plugins/clickatell.py:90 #: apprise/plugins/clicksend.py:112 apprise/plugins/d7networks.py:110 #: apprise/plugins/dapnet.py:138 apprise/plugins/dingtalk.py:111 #: apprise/plugins/email/base.py:140 apprise/plugins/fcm/__init__.py:168 #: apprise/plugins/flock.py:125 apprise/plugins/fortysixelks.py:111 #: apprise/plugins/httpsms.py:97 apprise/plugins/irc/base.py:149 #: apprise/plugins/join.py:172 apprise/plugins/kavenegar.py:135 #: apprise/plugins/line.py:93 apprise/plugins/mailgun.py:157 #: apprise/plugins/mastodon.py:190 apprise/plugins/matrix.py:262 #: apprise/plugins/mattermost.py:185 apprise/plugins/messagebird.py:99 #: apprise/plugins/mqtt.py:174 apprise/plugins/msg91.py:123 #: apprise/plugins/nextcloud.py:148 apprise/plugins/nextcloudtalk.py:101 #: apprise/plugins/notifiarr.py:103 apprise/plugins/notificationapi.py:187 #: apprise/plugins/ntfy.py:241 apprise/plugins/office365.py:148 #: apprise/plugins/one_signal.py:141 apprise/plugins/plivo.py:114 #: apprise/plugins/popcorn_notify.py:89 apprise/plugins/pushbullet.py:105 #: apprise/plugins/pushed.py:107 apprise/plugins/pushover.py:206 #: apprise/plugins/pushsafer.py:376 apprise/plugins/pushy.py:97 #: apprise/plugins/reddit.py:170 apprise/plugins/resend.py:124 #: apprise/plugins/revolt.py:112 apprise/plugins/rocketchat.py:166 #: apprise/plugins/sendgrid.py:133 apprise/plugins/sendpulse.py:136 #: apprise/plugins/seven.py:88 apprise/plugins/sfr.py:113 #: apprise/plugins/signal_api.py:138 apprise/plugins/sinch.py:140 #: apprise/plugins/slack.py:248 apprise/plugins/smpp.py:128 #: apprise/plugins/smseagle.py:172 apprise/plugins/smsmanager.py:119 #: apprise/plugins/sns.py:138 apprise/plugins/telegram.py:359 #: apprise/plugins/threema.py:115 apprise/plugins/twilio.py:174 #: apprise/plugins/twist.py:123 apprise/plugins/twitter.py:168 #: apprise/plugins/vapid/__init__.py:158 apprise/plugins/voipms.py:107 #: apprise/plugins/vonage.py:108 apprise/plugins/whatsapp.py:126 #: apprise/plugins/wxpusher.py:139 apprise/plugins/xmpp/base.py:118 #: apprise/plugins/zulip.py:153 msgid "Targets" msgstr "Targets" #: apprise/plugins/africas_talking.py:168 #, fuzzy msgid "From" msgstr "Rooms" #: apprise/plugins/africas_talking.py:174 apprise/plugins/bulksms.py:170 #: apprise/plugins/bulkvs.py:131 apprise/plugins/burstsms.py:166 #: apprise/plugins/clicksend.py:130 apprise/plugins/d7networks.py:128 #: apprise/plugins/dapnet.py:167 apprise/plugins/mailgun.py:194 #: apprise/plugins/mastodon.py:215 apprise/plugins/one_signal.py:161 #: apprise/plugins/opsgenie.py:288 apprise/plugins/plivo.py:137 #: apprise/plugins/popcorn_notify.py:104 apprise/plugins/signal_api.py:155 #: apprise/plugins/smseagle.py:190 apprise/plugins/smsmanager.py:152 #: apprise/plugins/smtp2go.py:151 apprise/plugins/sparkpost.py:209 #: apprise/plugins/twitter.py:193 #, fuzzy msgid "Batch Mode" msgstr "Webhook Mode" #: apprise/plugins/africas_talking.py:179 #, fuzzy msgid "SMS Mode" msgstr "Secure Mode" #: apprise/plugins/apprise_api.py:102 apprise/plugins/bark.py:134 #: apprise/plugins/custom_form.py:121 apprise/plugins/custom_json.py:103 #: apprise/plugins/custom_xml.py:103 apprise/plugins/emby.py:85 #: apprise/plugins/enigma2.py:110 apprise/plugins/fluxer.py:159 #: apprise/plugins/gotify.py:129 apprise/plugins/growl.py:140 #: apprise/plugins/home_assistant.py:79 apprise/plugins/irc/base.py:117 #: apprise/plugins/lametric.py:460 apprise/plugins/mastodon.py:168 #: apprise/plugins/matrix.py:220 apprise/plugins/mattermost.py:151 #: apprise/plugins/misskey.py:117 apprise/plugins/mqtt.py:147 #: apprise/plugins/nextcloud.py:116 apprise/plugins/nextcloudtalk.py:74 #: apprise/plugins/notica.py:126 apprise/plugins/ntfy.py:211 #: apprise/plugins/parseplatform.py:90 apprise/plugins/pushdeer.py:75 #: apprise/plugins/pushjet.py:71 apprise/plugins/rocketchat.py:120 #: apprise/plugins/rsyslog.py:180 apprise/plugins/signal_api.py:97 #: apprise/plugins/smseagle.py:135 apprise/plugins/synology.py:83 #: apprise/plugins/workflows.py:125 apprise/plugins/xbmc.py:96 #: apprise/plugins/xmpp/base.py:96 msgid "Hostname" msgstr "Hostname" #: apprise/plugins/apprise_api.py:107 apprise/plugins/bark.py:139 #: apprise/plugins/custom_form.py:126 apprise/plugins/custom_json.py:108 #: apprise/plugins/custom_xml.py:108 apprise/plugins/email/base.py:129 #: apprise/plugins/emby.py:90 apprise/plugins/enigma2.py:115 #: apprise/plugins/fluxer.py:163 apprise/plugins/gotify.py:140 #: apprise/plugins/growl.py:145 apprise/plugins/home_assistant.py:84 #: apprise/plugins/irc/base.py:122 apprise/plugins/lametric.py:464 #: apprise/plugins/mastodon.py:178 apprise/plugins/matrix.py:224 #: apprise/plugins/mattermost.py:167 apprise/plugins/misskey.py:127 #: apprise/plugins/mqtt.py:152 apprise/plugins/nextcloud.py:121 #: apprise/plugins/nextcloudtalk.py:79 apprise/plugins/notica.py:130 #: apprise/plugins/ntfy.py:215 apprise/plugins/parseplatform.py:95 #: apprise/plugins/pushdeer.py:79 apprise/plugins/pushjet.py:76 #: apprise/plugins/rocketchat.py:125 apprise/plugins/rsyslog.py:185 #: apprise/plugins/signal_api.py:102 apprise/plugins/smpp.py:108 #: apprise/plugins/smseagle.py:140 apprise/plugins/synology.py:88 #: apprise/plugins/workflows.py:130 apprise/plugins/xbmc.py:101 #: apprise/plugins/xmpp/base.py:101 msgid "Port" msgstr "Port" #: apprise/plugins/apprise_api.py:113 apprise/plugins/bark.py:145 #: apprise/plugins/bluesky.py:119 apprise/plugins/custom_form.py:132 #: apprise/plugins/custom_json.py:114 apprise/plugins/custom_xml.py:114 #: apprise/plugins/emby.py:97 apprise/plugins/enigma2.py:121 #: apprise/plugins/freemobile.py:78 apprise/plugins/home_assistant.py:90 #: apprise/plugins/lametric.py:471 apprise/plugins/matrix.py:230 #: apprise/plugins/nextcloud.py:127 apprise/plugins/nextcloudtalk.py:85 #: apprise/plugins/notica.py:136 apprise/plugins/ntfy.py:221 #: apprise/plugins/opsgenie.py:242 apprise/plugins/pushjet.py:88 #: apprise/plugins/rocketchat.py:131 apprise/plugins/signal_api.py:108 #: apprise/plugins/smpp.py:92 apprise/plugins/synology.py:94 #: apprise/plugins/xbmc.py:107 msgid "Username" msgstr "Username" #: apprise/plugins/apprise_api.py:117 apprise/plugins/aprs.py:172 #: apprise/plugins/bark.py:149 apprise/plugins/bluesky.py:124 #: apprise/plugins/bulksms.py:117 apprise/plugins/bulkvs.py:90 #: apprise/plugins/custom_form.py:136 apprise/plugins/custom_json.py:118 #: apprise/plugins/custom_xml.py:118 apprise/plugins/dapnet.py:123 #: apprise/plugins/email/base.py:119 apprise/plugins/emby.py:101 #: apprise/plugins/enigma2.py:125 apprise/plugins/freemobile.py:83 #: apprise/plugins/growl.py:151 apprise/plugins/home_assistant.py:94 #: apprise/plugins/irc/base.py:132 apprise/plugins/matrix.py:234 #: apprise/plugins/mqtt.py:163 apprise/plugins/nextcloud.py:131 #: apprise/plugins/nextcloudtalk.py:90 apprise/plugins/notica.py:140 #: apprise/plugins/ntfy.py:225 apprise/plugins/pushjet.py:92 #: apprise/plugins/reddit.py:145 apprise/plugins/rocketchat.py:135 #: apprise/plugins/signal_api.py:112 apprise/plugins/simplepush.py:108 #: apprise/plugins/smpp.py:97 apprise/plugins/synology.py:98 #: apprise/plugins/twist.py:101 apprise/plugins/voipms.py:88 #: apprise/plugins/xbmc.py:111 apprise/plugins/xmpp/base.py:112 msgid "Password" msgstr "Password" #: apprise/plugins/apprise_api.py:122 apprise/plugins/chanify.py:74 #: apprise/plugins/dingtalk.py:93 apprise/plugins/feishu.py:80 #: apprise/plugins/gotify.py:123 apprise/plugins/mattermost.py:157 #: apprise/plugins/notica.py:119 apprise/plugins/notifiarr.py:91 #: apprise/plugins/ntfy.py:230 apprise/plugins/pushme.py:62 #: apprise/plugins/ryver.py:99 apprise/plugins/serverchan.py:70 #: apprise/plugins/slack.py:294 apprise/plugins/synology.py:103 #: apprise/plugins/webexteams.py:116 apprise/plugins/zulip.py:136 msgid "Token" msgstr "Token" #: apprise/plugins/apprise_api.py:136 apprise/plugins/ntfy.py:288 #: apprise/plugins/opsgenie.py:307 apprise/plugins/pagertree.py:133 #, fuzzy msgid "Tags" msgstr "Targets" #: apprise/plugins/apprise_api.py:140 msgid "Query Method" msgstr "" #: apprise/plugins/apprise_api.py:154 apprise/plugins/custom_form.py:165 #: apprise/plugins/custom_json.py:141 apprise/plugins/custom_xml.py:141 #: apprise/plugins/enigma2.py:153 apprise/plugins/nextcloud.py:181 #: apprise/plugins/nextcloudtalk.py:122 apprise/plugins/notica.py:156 #: apprise/plugins/pagertree.py:142 apprise/plugins/synology.py:128 msgid "HTTP Header" msgstr "HTTP Header" #: apprise/plugins/aprs.py:167 apprise/plugins/bulksms.py:112 #: apprise/plugins/bulkvs.py:85 apprise/plugins/clicksend.py:93 #: apprise/plugins/dapnet.py:118 apprise/plugins/email/base.py:115 #: apprise/plugins/mailgun.py:136 apprise/plugins/mqtt.py:158 #: apprise/plugins/reddit.py:140 apprise/plugins/sendpulse.py:108 #: apprise/plugins/smtp2go.py:108 apprise/plugins/sparkpost.py:159 msgid "User Name" msgstr "User Name" #: apprise/plugins/aprs.py:178 apprise/plugins/aprs.py:199 #: apprise/plugins/dapnet.py:129 apprise/plugins/dapnet.py:150 #, fuzzy msgid "Target Callsign" msgstr "Target Emails" #: apprise/plugins/aprs.py:204 msgid "Resend Delay" msgstr "" #: apprise/plugins/aprs.py:211 msgid "Locale" msgstr "" #: apprise/plugins/bark.py:154 apprise/plugins/fcm/__init__.py:157 #: apprise/plugins/pushbullet.py:89 apprise/plugins/pushover.py:200 #: apprise/plugins/pushsafer.py:366 apprise/plugins/pushy.py:85 msgid "Target Device" msgstr "Target Device" #: apprise/plugins/bark.py:174 apprise/plugins/lametric.py:516 #: apprise/plugins/macosx.py:124 apprise/plugins/pushover.py:223 #: apprise/plugins/pushsafer.py:392 apprise/plugins/pushy.py:110 msgid "Sound" msgstr "Sound" #: apprise/plugins/bark.py:179 msgid "Level" msgstr "" #: apprise/plugins/bark.py:184 msgid "Volume" msgstr "" #: apprise/plugins/bark.py:190 apprise/plugins/ntfy.py:270 #: apprise/plugins/pagerduty.py:172 msgid "Click" msgstr "" #: apprise/plugins/bark.py:194 apprise/plugins/pushy.py:114 msgid "Badge" msgstr "" #: apprise/plugins/bark.py:199 msgid "Category" msgstr "" #: apprise/plugins/bark.py:203 apprise/plugins/join.py:158 #: apprise/plugins/pagerduty.py:163 msgid "Group" msgstr "Group" #: apprise/plugins/bark.py:207 apprise/plugins/dbus.py:225 #: apprise/plugins/discord.py:196 apprise/plugins/fcm/__init__.py:197 #: apprise/plugins/flock.py:136 apprise/plugins/fluxer.py:250 #: apprise/plugins/glib.py:187 apprise/plugins/gnome.py:154 #: apprise/plugins/growl.py:175 apprise/plugins/join.py:182 #: apprise/plugins/line.py:108 apprise/plugins/macosx.py:115 #: apprise/plugins/matrix.py:273 apprise/plugins/mattermost.py:211 #: apprise/plugins/msteams.py:200 apprise/plugins/notifiarr.py:125 #: apprise/plugins/ntfy.py:256 apprise/plugins/one_signal.py:155 #: apprise/plugins/pagerduty.py:191 apprise/plugins/ryver.py:124 #: apprise/plugins/slack.py:259 apprise/plugins/telegram.py:370 #: apprise/plugins/vapid/__init__.py:204 apprise/plugins/windows.py:106 #: apprise/plugins/workflows.py:162 apprise/plugins/xbmc.py:129 msgid "Include Image" msgstr "Include Image" #: apprise/plugins/bark.py:213 apprise/plugins/mattermost.py:207 #: apprise/plugins/revolt.py:128 msgid "Icon URL" msgstr "" #: apprise/plugins/bark.py:217 apprise/plugins/streamlabs.py:119 msgid "Call" msgstr "" #: apprise/plugins/base.py:192 msgid "Overflow Mode" msgstr "Overflow Mode" #: apprise/plugins/base.py:207 msgid "Notify Format" msgstr "Notify Format" #: apprise/plugins/base.py:217 #, fuzzy msgid "Interpret Emojis" msgstr "Target Emails" #: apprise/plugins/base.py:227 msgid "Persistent Storage" msgstr "" #: apprise/plugins/base.py:237 #, fuzzy msgid "Timezone" msgstr "Server Timeout" #: apprise/plugins/brevo.py:119 apprise/plugins/resend.py:114 #: apprise/plugins/sendgrid.py:123 #, fuzzy msgid "Source Email" msgstr "Source JID" #: apprise/plugins/brevo.py:124 apprise/plugins/email/base.py:135 #: apprise/plugins/mailgun.py:152 apprise/plugins/notificationapi.py:172 #: apprise/plugins/office365.py:143 apprise/plugins/one_signal.py:124 #: apprise/plugins/popcorn_notify.py:84 apprise/plugins/pushbullet.py:100 #: apprise/plugins/pushsafer.py:371 apprise/plugins/resend.py:119 #: apprise/plugins/sendgrid.py:128 apprise/plugins/sendpulse.py:131 #: apprise/plugins/slack.py:231 apprise/plugins/threema.py:105 msgid "Target Email" msgstr "Target Email" #: apprise/plugins/brevo.py:143 apprise/plugins/email/base.py:165 #: apprise/plugins/mailgun.py:186 apprise/plugins/notificationapi.py:218 #: apprise/plugins/office365.py:162 apprise/plugins/resend.py:138 #: apprise/plugins/sendgrid.py:147 apprise/plugins/sendpulse.py:152 #: apprise/plugins/ses.py:206 apprise/plugins/smtp2go.py:143 #: apprise/plugins/sparkpost.py:201 msgid "Carbon Copy" msgstr "" #: apprise/plugins/brevo.py:147 apprise/plugins/email/base.py:169 #: apprise/plugins/mailgun.py:190 apprise/plugins/notificationapi.py:222 #: apprise/plugins/office365.py:166 apprise/plugins/resend.py:142 #: apprise/plugins/sendgrid.py:151 apprise/plugins/sendpulse.py:156 #: apprise/plugins/ses.py:210 apprise/plugins/smtp2go.py:147 #: apprise/plugins/sparkpost.py:205 msgid "Blind Carbon Copy" msgstr "" #: apprise/plugins/brevo.py:151 apprise/plugins/ses.py:196 #, fuzzy msgid "Reply To Email" msgstr "To Email" #: apprise/plugins/bulksms.py:123 apprise/plugins/bulkvs.py:103 #: apprise/plugins/burstsms.py:124 apprise/plugins/clickatell.py:83 #: apprise/plugins/clicksend.py:105 apprise/plugins/d7networks.py:103 #: apprise/plugins/dingtalk.py:106 apprise/plugins/httpsms.py:90 #: apprise/plugins/kavenegar.py:128 apprise/plugins/messagebird.py:92 #: apprise/plugins/msg91.py:116 apprise/plugins/plivo.py:107 #: apprise/plugins/popcorn_notify.py:77 apprise/plugins/seven.py:81 #: apprise/plugins/signal_api.py:124 apprise/plugins/sinch.py:127 #: apprise/plugins/smpp.py:121 apprise/plugins/smseagle.py:151 #: apprise/plugins/smsmanager.py:112 apprise/plugins/sns.py:125 #: apprise/plugins/threema.py:98 apprise/plugins/twilio.py:161 #: apprise/plugins/voipms.py:100 apprise/plugins/vonage.py:101 #: apprise/plugins/whatsapp.py:119 msgid "Target Phone No" msgstr "Target Phone No" #: apprise/plugins/bulksms.py:130 apprise/plugins/nextcloud.py:142 #, fuzzy msgid "Target Group" msgstr "Target Topic" #: apprise/plugins/bulksms.py:152 apprise/plugins/bulkvs.py:96 #: apprise/plugins/bulkvs.py:125 apprise/plugins/clickatell.py:78 #: apprise/plugins/fortysixelks.py:100 apprise/plugins/httpsms.py:83 #: apprise/plugins/httpsms.py:115 apprise/plugins/signal_api.py:117 #: apprise/plugins/sinch.py:120 apprise/plugins/smpp.py:114 #: apprise/plugins/smsmanager.py:137 apprise/plugins/twilio.py:154 #: apprise/plugins/voipms.py:94 apprise/plugins/vonage.py:94 msgid "From Phone No" msgstr "From Phone No" #: apprise/plugins/bulksms.py:158 #, fuzzy msgid "Route Group" msgstr "Group" #: apprise/plugins/bulksms.py:165 apprise/plugins/d7networks.py:123 msgid "Unicode Characters" msgstr "" #: apprise/plugins/burstsms.py:111 apprise/plugins/threema.py:92 #: apprise/plugins/vonage.py:87 #, fuzzy msgid "API Secret" msgstr "Application Secret" #: apprise/plugins/burstsms.py:118 #, fuzzy msgid "Sender ID" msgstr "To User ID" #: apprise/plugins/burstsms.py:155 msgid "Country" msgstr "" #: apprise/plugins/burstsms.py:164 msgid "validity" msgstr "" #: apprise/plugins/chanify.py:47 msgid "Chanify" msgstr "" #: apprise/plugins/clickatell.py:45 msgid "Clickatell" msgstr "" #: apprise/plugins/clickatell.py:72 apprise/plugins/rocketchat.py:140 #, fuzzy msgid "API Token" msgstr "API Key" #: apprise/plugins/custom_form.py:148 apprise/plugins/custom_json.py:130 #: apprise/plugins/custom_xml.py:130 msgid "Fetch Method" msgstr "" #: apprise/plugins/custom_form.py:154 msgid "Attach File As" msgstr "" #: apprise/plugins/custom_form.py:169 apprise/plugins/custom_json.py:145 #: apprise/plugins/custom_xml.py:145 apprise/plugins/pagertree.py:146 msgid "Payload Extras" msgstr "" #: apprise/plugins/custom_form.py:173 apprise/plugins/custom_json.py:149 #: apprise/plugins/custom_xml.py:149 msgid "GET Params" msgstr "" #: apprise/plugins/d7networks.py:97 #, fuzzy msgid "API Access Token" msgstr "Access Token" #: apprise/plugins/d7networks.py:141 apprise/plugins/seven.py:107 msgid "Originating Address" msgstr "" #: apprise/plugins/dapnet.py:155 apprise/plugins/gotify.py:153 #: apprise/plugins/growl.py:163 apprise/plugins/join.py:188 #: apprise/plugins/lametric.py:494 apprise/plugins/ntfy.py:282 #: apprise/plugins/opsgenie.py:293 apprise/plugins/prowl.py:141 #: apprise/plugins/pushover.py:217 apprise/plugins/pushsafer.py:387 #: apprise/plugins/smseagle.py:210 msgid "Priority" msgstr "Priority" #: apprise/plugins/dapnet.py:161 msgid "Transmitter Groups" msgstr "" #: apprise/plugins/dbus.py:153 msgid "libdbus-1.so.x must be installed." msgstr "" #: apprise/plugins/dbus.py:157 apprise/plugins/glib.py:126 msgid "DBus Notification" msgstr "" #: apprise/plugins/dbus.py:201 apprise/plugins/glib.py:163 #: apprise/plugins/gnome.py:142 apprise/plugins/pagertree.py:128 msgid "Urgency" msgstr "Urgency" #: apprise/plugins/dbus.py:213 apprise/plugins/glib.py:175 msgid "X-Axis" msgstr "X-Axis" #: apprise/plugins/dbus.py:219 apprise/plugins/glib.py:181 msgid "Y-Axis" msgstr "Y-Axis" #: apprise/plugins/dingtalk.py:100 apprise/plugins/signl4.py:76 #, fuzzy msgid "Secret" msgstr "Secret Key" #: apprise/plugins/discord.py:125 apprise/plugins/flock.py:106 #: apprise/plugins/fluxer.py:169 apprise/plugins/ryver.py:106 #: apprise/plugins/slack.py:186 apprise/plugins/viber.py:95 #: apprise/plugins/zulip.py:124 msgid "Bot Name" msgstr "Bot Name" #: apprise/plugins/discord.py:130 apprise/plugins/fluxer.py:174 #: apprise/plugins/ifttt.py:103 msgid "Webhook ID" msgstr "Webhook ID" #: apprise/plugins/discord.py:136 apprise/plugins/fluxer.py:181 #: apprise/plugins/google_chat.py:118 msgid "Webhook Token" msgstr "Webhook Token" #: apprise/plugins/discord.py:149 apprise/plugins/fluxer.py:201 msgid "Text To Speech" msgstr "Text To Speech" #: apprise/plugins/discord.py:154 apprise/plugins/fluxer.py:206 msgid "Avatar Image" msgstr "Avatar Image" #: apprise/plugins/discord.py:159 apprise/plugins/fluxer.py:211 #: apprise/plugins/ntfy.py:262 #, fuzzy msgid "Avatar URL" msgstr "Avatar Image" #: apprise/plugins/discord.py:163 apprise/plugins/fluxer.py:215 #: apprise/plugins/pushover.py:229 msgid "URL" msgstr "" #: apprise/plugins/discord.py:172 apprise/plugins/fluxer.py:222 msgid "Thread ID" msgstr "" #: apprise/plugins/discord.py:176 apprise/plugins/fluxer.py:230 msgid "Display Footer" msgstr "Display Footer" #: apprise/plugins/discord.py:181 apprise/plugins/fluxer.py:235 msgid "Footer Logo" msgstr "Footer Logo" #: apprise/plugins/discord.py:186 apprise/plugins/fluxer.py:240 #, fuzzy msgid "Use Fields" msgstr "To User ID" #: apprise/plugins/discord.py:191 apprise/plugins/fluxer.py:245 msgid "Discord Flags" msgstr "" #: apprise/plugins/discord.py:205 apprise/plugins/fluxer.py:256 msgid "Ping Users/Roles" msgstr "" #: apprise/plugins/dot.py:127 #, fuzzy msgid "Device Serial Number" msgstr "Device ID" #: apprise/plugins/dot.py:133 #, fuzzy msgid "API Mode" msgstr "API Key" #: apprise/plugins/dot.py:147 msgid "Refresh Now" msgstr "" #: apprise/plugins/dot.py:153 msgid "Text Signature" msgstr "" #: apprise/plugins/dot.py:157 msgid "Icon Base64 (Text API)" msgstr "" #: apprise/plugins/dot.py:161 msgid "Image Base64 (Image API)" msgstr "" #: apprise/plugins/dot.py:166 msgid "Link" msgstr "" #: apprise/plugins/dot.py:170 #, fuzzy msgid "Border" msgstr "Modal" #: apprise/plugins/dot.py:177 msgid "Dither Type" msgstr "" #: apprise/plugins/dot.py:183 msgid "Dither Kernel" msgstr "" #: apprise/plugins/emby.py:112 msgid "Modal" msgstr "Modal" #: apprise/plugins/enigma2.py:130 apprise/plugins/gotify.py:134 #: apprise/plugins/mattermost.py:163 apprise/plugins/notica.py:145 msgid "Path" msgstr "" #: apprise/plugins/enigma2.py:140 msgid "Server Timeout" msgstr "Server Timeout" #: apprise/plugins/feishu.py:49 msgid "Feishu" msgstr "" #: apprise/plugins/flock.py:99 apprise/plugins/twitter.py:150 msgid "Access Key" msgstr "Access Key" #: apprise/plugins/flock.py:111 msgid "To User ID" msgstr "To User ID" #: apprise/plugins/flock.py:118 msgid "To Channel ID" msgstr "To Channel ID" #: apprise/plugins/fcm/__init__.py:182 apprise/plugins/fcm/__init__.py:188 #: apprise/plugins/fluxer.py:195 apprise/plugins/lametric.py:510 #: apprise/plugins/mattermost.py:223 apprise/plugins/notificationapi.py:209 #: apprise/plugins/ntfy.py:296 apprise/plugins/vapid/__init__.py:169 #, fuzzy msgid "Mode" msgstr "Modal" #: apprise/plugins/fluxer.py:226 #, fuzzy msgid "Thread Name" msgstr "Bot Name" #: apprise/plugins/fortysixelks.py:58 msgid "46elks" msgstr "" #: apprise/plugins/fortysixelks.py:89 #, fuzzy msgid "API Username" msgstr "User Name" #: apprise/plugins/fortysixelks.py:94 #, fuzzy msgid "API Password" msgstr "Password" #: apprise/plugins/freemobile.py:48 msgid "Free-Mobile" msgstr "" #: apprise/plugins/glib.py:122 msgid "libdbus-1.so.x or libdbus-2.so.x must be installed." msgstr "" #: apprise/plugins/gnome.py:100 msgid "A local Gnome environment is required." msgstr "" #: apprise/plugins/gnome.py:104 msgid "Gnome Notification" msgstr "" #: apprise/plugins/google_chat.py:106 msgid "Workspace" msgstr "" #: apprise/plugins/google_chat.py:112 #, fuzzy msgid "Webhook Key" msgstr "Webhook Token" #: apprise/plugins/google_chat.py:124 #, fuzzy msgid "Thread Key" msgstr "Secret Key" #: apprise/plugins/growl.py:169 apprise/plugins/mqtt.py:195 #: apprise/plugins/msteams.py:206 apprise/plugins/nextcloud.py:163 msgid "Version" msgstr "Version" #: apprise/plugins/growl.py:181 msgid "Sticky" msgstr "" #: apprise/plugins/home_assistant.py:99 #, fuzzy msgid "Long-Lived Access Token" msgstr "Access Token" #: apprise/plugins/home_assistant.py:113 msgid "Notification ID" msgstr "" #: apprise/plugins/ifttt.py:109 msgid "Events" msgstr "Events" #: apprise/plugins/ifttt.py:129 msgid "Add Tokens" msgstr "Add Tokens" #: apprise/plugins/ifttt.py:133 msgid "Remove Tokens" msgstr "Remove Tokens" #: apprise/plugins/join.py:147 msgid "Device ID" msgstr "Device ID" #: apprise/plugins/join.py:153 #, fuzzy msgid "Device Name" msgstr "Device ID" #: apprise/plugins/kavenegar.py:122 apprise/plugins/messagebird.py:85 #: apprise/plugins/plivo.py:100 #, fuzzy msgid "Source Phone No" msgstr "Target Phone No" #: apprise/plugins/kumulos.py:99 #, fuzzy msgid "Server Key" msgstr "Secret Key" #: apprise/plugins/lametric.py:436 #, fuzzy msgid "Device API Key" msgstr "Device ID" #: apprise/plugins/lametric.py:442 apprise/plugins/one_signal.py:102 #: apprise/plugins/parseplatform.py:101 msgid "App ID" msgstr "" #: apprise/plugins/lametric.py:448 #, fuzzy msgid "App Version" msgstr "Version" #: apprise/plugins/lametric.py:455 #, fuzzy msgid "App Access Token" msgstr "Access Token" #: apprise/plugins/lametric.py:500 msgid "Custom Icon" msgstr "" #: apprise/plugins/lametric.py:504 msgid "Icon Type" msgstr "" #: apprise/plugins/lametric.py:521 msgid "Cycles" msgstr "" #: apprise/plugins/lark.py:47 msgid "Lark (Feishu)" msgstr "" #: apprise/plugins/lark.py:67 apprise/plugins/revolt.py:98 #: apprise/plugins/telegram.py:344 msgid "Bot Token" msgstr "Bot Token" #: apprise/plugins/line.py:82 apprise/plugins/mastodon.py:173 #: apprise/plugins/matrix.py:239 apprise/plugins/misskey.py:122 #: apprise/plugins/pushbullet.py:83 apprise/plugins/pushover.py:194 #: apprise/plugins/smseagle.py:146 apprise/plugins/spugpush.py:68 #: apprise/plugins/streamlabs.py:105 apprise/plugins/whatsapp.py:99 msgid "Access Token" msgstr "Access Token" #: apprise/plugins/irc/base.py:137 apprise/plugins/line.py:88 #: apprise/plugins/mastodon.py:184 apprise/plugins/matrix.py:244 #: apprise/plugins/nextcloud.py:136 apprise/plugins/one_signal.py:129 #: apprise/plugins/opsgenie.py:258 apprise/plugins/pushed.py:95 #: apprise/plugins/rocketchat.py:155 apprise/plugins/slack.py:236 #: apprise/plugins/twitter.py:162 apprise/plugins/zulip.py:143 msgid "Target User" msgstr "Target User" #: apprise/plugins/macosx.py:65 msgid "" "Only works with Mac OS X 10.8 and higher. Additionally requires that /usr/" "local/bin/terminal-notifier is locally accessible." msgstr "" #: apprise/plugins/macosx.py:72 msgid "MacOSX Notification" msgstr "" #: apprise/plugins/macosx.py:128 msgid "Open/Click URL" msgstr "" #: apprise/plugins/email/base.py:124 apprise/plugins/mailgun.py:141 #: apprise/plugins/sendpulse.py:112 apprise/plugins/smtp2go.py:113 #: apprise/plugins/sparkpost.py:164 msgid "Domain" msgstr "Domain" #: apprise/plugins/email/base.py:160 apprise/plugins/mailgun.py:168 #: apprise/plugins/resend.py:154 apprise/plugins/ses.py:201 #: apprise/plugins/smtp2go.py:135 apprise/plugins/sparkpost.py:186 msgid "From Name" msgstr "From Name" #: apprise/plugins/mailgun.py:176 apprise/plugins/notificationapi.py:203 #: apprise/plugins/opsgenie.py:281 apprise/plugins/pagerduty.py:176 #: apprise/plugins/sparkpost.py:191 msgid "Region Name" msgstr "Region Name" #: apprise/plugins/email/base.py:209 apprise/plugins/mailgun.py:204 #: apprise/plugins/smtp2go.py:161 apprise/plugins/sparkpost.py:219 #, fuzzy msgid "Email Header" msgstr "HTTP Header" #: apprise/plugins/mailgun.py:208 apprise/plugins/msteams.py:222 #: apprise/plugins/notificationapi.py:246 apprise/plugins/sparkpost.py:223 #: apprise/plugins/workflows.py:201 #, fuzzy msgid "Template Tokens" msgstr "Remove Tokens" #: apprise/plugins/mastodon.py:204 apprise/plugins/misskey.py:143 msgid "Visibility" msgstr "" #: apprise/plugins/mastodon.py:210 apprise/plugins/twitter.py:185 msgid "Cache Results" msgstr "" #: apprise/plugins/mastodon.py:220 msgid "Sensitive Attachments" msgstr "" #: apprise/plugins/mastodon.py:225 msgid "Spoiler Text" msgstr "" #: apprise/plugins/mastodon.py:229 msgid "Idempotency-Key" msgstr "" #: apprise/plugins/mastodon.py:233 msgid "Language Code" msgstr "" #: apprise/plugins/matrix.py:250 apprise/plugins/rocketchat.py:161 msgid "Target Room ID" msgstr "Target Room ID" #: apprise/plugins/matrix.py:256 msgid "Target Room Alias" msgstr "Target Room Alias" #: apprise/plugins/matrix.py:279 #, fuzzy msgid "Server Discovery" msgstr "Server Timeout" #: apprise/plugins/matrix.py:284 msgid "Force Home Server on Room IDs" msgstr "" #: apprise/plugins/matrix.py:289 apprise/plugins/rocketchat.py:177 #: apprise/plugins/ryver.py:118 msgid "Webhook Mode" msgstr "Webhook Mode" #: apprise/plugins/matrix.py:295 msgid "Matrix API Verion" msgstr "" #: apprise/plugins/matrix.py:301 apprise/plugins/notificationapi.py:154 msgid "Message Type" msgstr "" #: apprise/plugins/irc/base.py:128 apprise/plugins/mattermost.py:147 #: apprise/plugins/xmpp/base.py:107 #, fuzzy msgid "User" msgstr "Username" #: apprise/plugins/irc/base.py:143 apprise/plugins/mattermost.py:173 #: apprise/plugins/notifiarr.py:97 apprise/plugins/pushbullet.py:94 #: apprise/plugins/pushed.py:101 apprise/plugins/rocketchat.py:149 #: apprise/plugins/slack.py:242 apprise/plugins/twist.py:112 msgid "Target Channel" msgstr "Target Channel" #: apprise/plugins/mattermost.py:179 apprise/plugins/twist.py:118 #, fuzzy msgid "Target Channel ID" msgstr "Target Channel" #: apprise/plugins/mqtt.py:169 #, fuzzy msgid "Target Queue" msgstr "Target User" #: apprise/plugins/mqtt.py:188 msgid "QOS" msgstr "" #: apprise/plugins/mqtt.py:201 apprise/plugins/notificationapi.py:161 #: apprise/plugins/office365.py:130 apprise/plugins/sendpulse.py:117 #, fuzzy msgid "Client ID" msgstr "Account SID" #: apprise/plugins/mqtt.py:205 msgid "Use Session" msgstr "" #: apprise/plugins/mqtt.py:210 msgid "Retain Messages" msgstr "" #: apprise/plugins/msg91.py:102 apprise/plugins/sendpulse.py:161 msgid "Template ID" msgstr "" #: apprise/plugins/msg91.py:109 #, fuzzy msgid "Authentication Key" msgstr "Application Key" #: apprise/plugins/msg91.py:138 msgid "Short URL" msgstr "" #: apprise/plugins/msg91.py:148 apprise/plugins/whatsapp.py:168 msgid "Template Mapping" msgstr "" #: apprise/plugins/msteams.py:151 #, fuzzy msgid "Team Name" msgstr "Bot Name" #: apprise/plugins/msteams.py:159 apprise/plugins/slack.py:203 msgid "Token A" msgstr "Token A" #: apprise/plugins/msteams.py:168 apprise/plugins/slack.py:211 msgid "Token B" msgstr "Token B" #: apprise/plugins/msteams.py:177 apprise/plugins/slack.py:219 msgid "Token C" msgstr "Token C" #: apprise/plugins/msteams.py:186 #, fuzzy msgid "Token D" msgstr "Token C" #: apprise/plugins/msteams.py:212 apprise/plugins/workflows.py:180 msgid "Template Path" msgstr "" #: apprise/plugins/nextcloud.py:169 apprise/plugins/nextcloudtalk.py:113 msgid "URL Prefix" msgstr "" #: apprise/plugins/nextcloudtalk.py:43 msgid "Nextcloud Talk" msgstr "" #: apprise/plugins/nextcloudtalk.py:96 #, fuzzy msgid "Room ID" msgstr "Target Room ID" #: apprise/plugins/notifiarr.py:121 msgid "Discord Event ID" msgstr "" #: apprise/plugins/notifiarr.py:131 apprise/plugins/pagerduty.py:145 #, fuzzy msgid "Source" msgstr "Source JID" #: apprise/plugins/notificationapi.py:166 apprise/plugins/office365.py:137 #: apprise/plugins/sendpulse.py:124 #, fuzzy msgid "Client Secret" msgstr "Access Secret" #: apprise/plugins/notificationapi.py:177 #, fuzzy msgid "Target ID" msgstr "Target User" #: apprise/plugins/notificationapi.py:182 #, fuzzy msgid "Target SMS" msgstr "Targets" #: apprise/plugins/notificationapi.py:198 msgid "Channels" msgstr "Channels" #: apprise/plugins/email/base.py:185 apprise/plugins/notificationapi.py:226 #: apprise/plugins/resend.py:146 msgid "Reply To" msgstr "" #: apprise/plugins/email/base.py:155 apprise/plugins/notificationapi.py:231 #: apprise/plugins/sendpulse.py:147 apprise/plugins/ses.py:154 msgid "From Email" msgstr "From Email" #: apprise/plugins/fcm/__init__.py:153 apprise/plugins/notifico.py:124 #, fuzzy msgid "Project ID" msgstr "Target JID" #: apprise/plugins/notifico.py:133 msgid "Message Hook" msgstr "" #: apprise/plugins/notifico.py:148 msgid "IRC Colors" msgstr "" #: apprise/plugins/notifico.py:154 msgid "Prefix" msgstr "" #: apprise/plugins/ntfy.py:235 msgid "Topic" msgstr "" #: apprise/plugins/ntfy.py:252 msgid "Attach" msgstr "" #: apprise/plugins/ntfy.py:266 msgid "Attach Filename" msgstr "" #: apprise/plugins/ntfy.py:274 msgid "Delay" msgstr "" #: apprise/plugins/ntfy.py:278 apprise/plugins/twist.py:107 #, fuzzy msgid "Email" msgstr "To Email" #: apprise/plugins/ntfy.py:292 #, fuzzy msgid "Actions" msgstr "Duration" #: apprise/plugins/ntfy.py:305 #, fuzzy msgid "Authentication Type" msgstr "Authorization Token" #: apprise/plugins/office365.py:118 #, fuzzy msgid "Tenant Domain" msgstr "Domain" #: apprise/plugins/office365.py:125 msgid "Account Email or Object ID" msgstr "" #: apprise/plugins/one_signal.py:108 apprise/plugins/sendgrid.py:157 msgid "Template" msgstr "" #: apprise/plugins/one_signal.py:119 #, fuzzy msgid "Target Player ID" msgstr "Target Tag ID" #: apprise/plugins/one_signal.py:135 #, fuzzy msgid "Include Segment" msgstr "Include Image" #: apprise/plugins/one_signal.py:166 msgid "Enable Contents" msgstr "" #: apprise/plugins/one_signal.py:172 msgid "Decode Template Args" msgstr "" #: apprise/plugins/one_signal.py:181 msgid "Subtitle" msgstr "" #: apprise/plugins/one_signal.py:185 apprise/plugins/sfr.py:125 #: apprise/plugins/whatsapp.py:130 msgid "Language" msgstr "" #: apprise/plugins/one_signal.py:195 msgid "Custom Data" msgstr "" #: apprise/plugins/one_signal.py:199 msgid "Postback Data" msgstr "" #: apprise/plugins/opsgenie.py:246 #, fuzzy msgid "Target Escalation" msgstr "Target Chat ID" #: apprise/plugins/opsgenie.py:252 #, fuzzy msgid "Target Schedule" msgstr "Target Channel" #: apprise/plugins/opsgenie.py:264 #, fuzzy msgid "Target Team" msgstr "Target Email" #: apprise/plugins/opsgenie.py:270 #, fuzzy msgid "Targets " msgstr "Targets" #: apprise/plugins/opsgenie.py:299 msgid "Entity" msgstr "" #: apprise/plugins/opsgenie.py:303 msgid "Alias" msgstr "" #: apprise/plugins/opsgenie.py:314 apprise/plugins/pagertree.py:118 #: apprise/plugins/splunk.py:202 #, fuzzy msgid "Action" msgstr "Duration" #: apprise/plugins/opsgenie.py:325 #, fuzzy msgid "Details" msgstr "Target Emails" #: apprise/plugins/opsgenie.py:329 apprise/plugins/splunk.py:213 msgid "Action Mapping" msgstr "" #: apprise/plugins/pagerduty.py:138 apprise/plugins/spike.py:68 #, fuzzy msgid "Integration Key" msgstr "Application Key" #: apprise/plugins/pagerduty.py:151 #, fuzzy msgid "Component" msgstr "From Phone No" #: apprise/plugins/pagerduty.py:167 msgid "Class" msgstr "" #: apprise/plugins/pagerduty.py:185 msgid "Severity" msgstr "" #: apprise/plugins/pagerduty.py:202 #, fuzzy msgid "Custom Details" msgstr "To Email" #: apprise/plugins/pagertree.py:105 msgid "Integration ID" msgstr "" #: apprise/plugins/pagertree.py:124 msgid "Third Party ID" msgstr "" #: apprise/plugins/pagertree.py:150 msgid "Meta Extras" msgstr "" #: apprise/plugins/parseplatform.py:107 #, fuzzy msgid "Master Key" msgstr "User Key" #: apprise/plugins/parseplatform.py:120 #, fuzzy msgid "Device" msgstr "Device ID" #: apprise/plugins/plivo.py:88 #, fuzzy msgid "Auth ID" msgstr "Account SID" #: apprise/plugins/plivo.py:94 apprise/plugins/sinch.py:113 #: apprise/plugins/twilio.py:147 msgid "Auth Token" msgstr "Auth Token" #: apprise/plugins/prowl.py:128 msgid "Provider Key" msgstr "Provider Key" #: apprise/plugins/pushdeer.py:85 #, fuzzy msgid "Pushkey" msgstr "User Key" #: apprise/plugins/pushed.py:83 msgid "Application Key" msgstr "Application Key" #: apprise/plugins/pushed.py:89 apprise/plugins/reddit.py:158 msgid "Application Secret" msgstr "Application Secret" #: apprise/plugins/pushjet.py:82 msgid "Secret Key" msgstr "Secret Key" #: apprise/plugins/pushme.py:81 apprise/plugins/signal_api.py:160 #: apprise/plugins/smseagle.py:195 msgid "Show Status" msgstr "" #: apprise/plugins/pushover.py:188 msgid "User Key" msgstr "User Key" #: apprise/plugins/pushover.py:234 msgid "URL Title" msgstr "" #: apprise/plugins/pushover.py:239 msgid "Retry" msgstr "" #: apprise/plugins/pushover.py:245 msgid "Expire" msgstr "" #: apprise/plugins/pushplus.py:48 msgid "Pushplus" msgstr "" #: apprise/plugins/pushplus.py:68 apprise/plugins/qq.py:66 #, fuzzy msgid "User Token" msgstr "User Key" #: apprise/plugins/pushsafer.py:360 #, fuzzy msgid "Private Key" msgstr "Provider Key" #: apprise/plugins/pushsafer.py:397 #, fuzzy msgid "Vibration" msgstr "Duration" #: apprise/plugins/pushy.py:79 #, fuzzy msgid "Secret API Key" msgstr "Secret Key" #: apprise/plugins/fcm/__init__.py:162 apprise/plugins/pushy.py:91 #: apprise/plugins/sns.py:131 apprise/plugins/wxpusher.py:128 msgid "Target Topic" msgstr "Target Topic" #: apprise/plugins/qq.py:46 msgid "QQ Push" msgstr "" #: apprise/plugins/reddit.py:151 #, fuzzy msgid "Application ID" msgstr "Application Key" #: apprise/plugins/reddit.py:165 #, fuzzy msgid "Target Subreddit" msgstr "Target User" #: apprise/plugins/reddit.py:185 msgid "Kind" msgstr "" #: apprise/plugins/reddit.py:191 msgid "Flair ID" msgstr "" #: apprise/plugins/reddit.py:196 msgid "Flair Text" msgstr "" #: apprise/plugins/reddit.py:201 msgid "NSFW" msgstr "" #: apprise/plugins/reddit.py:207 msgid "Is Ad?" msgstr "" #: apprise/plugins/reddit.py:213 msgid "Send Replies" msgstr "" #: apprise/plugins/reddit.py:219 msgid "Is Spoiler" msgstr "" #: apprise/plugins/reddit.py:225 msgid "Resubmit Flag" msgstr "" #: apprise/plugins/revolt.py:104 #, fuzzy msgid "Channel ID" msgstr "To Channel ID" #: apprise/plugins/revolt.py:130 msgid "Embed URL" msgstr "" #: apprise/plugins/rocketchat.py:145 msgid "Webhook" msgstr "Webhook" #: apprise/plugins/rocketchat.py:182 msgid "Use Avatar" msgstr "Use Avatar" #: apprise/plugins/rsyslog.py:173 apprise/plugins/syslog.py:144 msgid "Facility" msgstr "" #: apprise/plugins/rsyslog.py:203 apprise/plugins/syslog.py:161 msgid "Log PID" msgstr "" #: apprise/plugins/ryver.py:93 apprise/plugins/zulip.py:130 msgid "Organization" msgstr "Organization" #: apprise/plugins/sendgrid.py:166 apprise/plugins/sendpulse.py:175 msgid "Template Data" msgstr "" #: apprise/plugins/ses.py:160 apprise/plugins/sns.py:106 msgid "Access Key ID" msgstr "Access Key ID" #: apprise/plugins/ses.py:166 apprise/plugins/sns.py:112 msgid "Secret Access Key" msgstr "Secret Access Key" #: apprise/plugins/ses.py:172 apprise/plugins/sinch.py:160 #: apprise/plugins/sns.py:118 msgid "Region" msgstr "Region" #: apprise/plugins/ses.py:179 apprise/plugins/smtp2go.py:124 #: apprise/plugins/sparkpost.py:175 msgid "Target Emails" msgstr "Target Emails" #: apprise/plugins/seven.py:115 apprise/plugins/smseagle.py:205 msgid "Flash" msgstr "" #: apprise/plugins/seven.py:119 msgid "Label" msgstr "" #: apprise/plugins/sfr.py:58 msgid "Société Française du Radiotéléphone" msgstr "" #: apprise/plugins/sfr.py:90 #, fuzzy msgid "Service ID" msgstr "Device ID" #: apprise/plugins/sfr.py:95 #, fuzzy msgid "Service Password" msgstr "Password" #: apprise/plugins/sfr.py:101 #, fuzzy msgid "Space ID" msgstr "Source JID" #: apprise/plugins/sfr.py:107 msgid "Recipient Phone Number" msgstr "" #: apprise/plugins/sfr.py:131 #, fuzzy msgid "Sender Name" msgstr "User Name" #: apprise/plugins/sfr.py:138 msgid "Media Type" msgstr "" #: apprise/plugins/sfr.py:145 #, fuzzy msgid "Timeout" msgstr "Server Timeout" #: apprise/plugins/sfr.py:151 #, fuzzy msgid "TTS Voice" msgstr "Target Device" #: apprise/plugins/signal_api.py:131 apprise/plugins/smseagle.py:158 #, fuzzy msgid "Target Group ID" msgstr "Target Room ID" #: apprise/plugins/signl4.py:86 #, fuzzy msgid "Service" msgstr "Device ID" #: apprise/plugins/signl4.py:90 #, fuzzy msgid "Location" msgstr "Duration" #: apprise/plugins/signl4.py:94 msgid "Alerting Scenario" msgstr "" #: apprise/plugins/signl4.py:98 msgid "Filtering" msgstr "" #: apprise/plugins/signl4.py:103 #, fuzzy msgid "External ID" msgstr "To User ID" #: apprise/plugins/signl4.py:107 #, fuzzy msgid "Status" msgstr "Targets" #: apprise/plugins/simplepush.py:113 msgid "Salt" msgstr "" #: apprise/plugins/simplepush.py:126 #, fuzzy msgid "Event" msgstr "Events" #: apprise/plugins/sinch.py:106 apprise/plugins/twilio.py:140 msgid "Account SID" msgstr "Account SID" #: apprise/plugins/sinch.py:134 apprise/plugins/twilio.py:168 msgid "Target Short Code" msgstr "Target Short Code" #: apprise/plugins/slack.py:194 #, fuzzy msgid "OAuth Access Token" msgstr "Access Token" #: apprise/plugins/slack.py:225 msgid "Target Encoded ID" msgstr "Target Encoded ID" #: apprise/plugins/slack.py:265 #, fuzzy msgid "Include Footer" msgstr "Include Image" #: apprise/plugins/slack.py:273 msgid "Use Blocks" msgstr "" #: apprise/plugins/slack.py:282 #, fuzzy msgid "Include Timestamp" msgstr "Include Image" #: apprise/plugins/slack.py:288 apprise/plugins/twitter.py:179 #, fuzzy msgid "Message Mode" msgstr "Secure Mode" #: apprise/plugins/smpp.py:61 msgid "SMPP" msgstr "" #: apprise/plugins/smpp.py:103 #, fuzzy msgid "Host" msgstr "Hostname" #: apprise/plugins/smseagle.py:165 #, fuzzy msgid "Target Contact" msgstr "Target Chat ID" #: apprise/plugins/smseagle.py:200 msgid "Test Only" msgstr "" #: apprise/plugins/smsmanager.py:146 msgid "Gateway" msgstr "" #: apprise/plugins/spike.py:48 msgid "Spike.sh" msgstr "" #: apprise/plugins/splunk.py:117 msgid "Splunk On-Call" msgstr "" #: apprise/plugins/splunk.py:172 #, fuzzy msgid "Target Routing Key" msgstr "Target Tag ID" #: apprise/plugins/splunk.py:179 msgid "Entity ID" msgstr "" #: apprise/plugins/spugpush.py:48 msgid "SpugPush" msgstr "" #: apprise/plugins/streamlabs.py:125 msgid "Alert Type" msgstr "" #: apprise/plugins/streamlabs.py:131 msgid "Image Link" msgstr "" #: apprise/plugins/streamlabs.py:136 #, fuzzy msgid "Sound Link" msgstr "Sound" #: apprise/plugins/streamlabs.py:141 apprise/plugins/windows.py:100 #: apprise/plugins/xbmc.py:123 msgid "Duration" msgstr "Duration" #: apprise/plugins/streamlabs.py:147 msgid "Special Text Color" msgstr "" #: apprise/plugins/streamlabs.py:153 msgid "Amount" msgstr "" #: apprise/plugins/streamlabs.py:159 #, fuzzy msgid "Currency" msgstr "Urgency" #: apprise/plugins/streamlabs.py:165 #, fuzzy msgid "Name" msgstr "Username" #: apprise/plugins/streamlabs.py:171 msgid "Identifier" msgstr "" #: apprise/plugins/synology.py:116 msgid "Upload" msgstr "" #: apprise/plugins/syslog.py:167 msgid "Log to STDERR" msgstr "" #: apprise/plugins/telegram.py:353 msgid "Target Chat ID" msgstr "Target Chat ID" #: apprise/plugins/telegram.py:376 msgid "Detect Bot Owner" msgstr "Detect Bot Owner" #: apprise/plugins/telegram.py:382 msgid "Silent Notification" msgstr "" #: apprise/plugins/telegram.py:387 msgid "Web Page Preview" msgstr "" #: apprise/plugins/telegram.py:392 msgid "Topic Thread ID" msgstr "" #: apprise/plugins/telegram.py:399 #, fuzzy msgid "Markdown Version" msgstr "Version" #: apprise/plugins/telegram.py:408 msgid "Content Placement" msgstr "" #: apprise/plugins/threema.py:85 msgid "Gateway ID" msgstr "" #: apprise/plugins/threema.py:110 #, fuzzy msgid "Target Threema ID" msgstr "Target Tag ID" #: apprise/plugins/twilio.py:203 msgid "Notification Method: sms or call" msgstr "" #: apprise/plugins/twitter.py:138 msgid "Consumer Key" msgstr "Consumer Key" #: apprise/plugins/twitter.py:144 msgid "Consumer Secret" msgstr "Consumer Secret" #: apprise/plugins/twitter.py:156 msgid "Access Secret" msgstr "Access Secret" #: apprise/plugins/viber.py:49 msgid "Viber" msgstr "" #: apprise/plugins/viber.py:81 #, fuzzy msgid "Authentication Token" msgstr "Application Key" #: apprise/plugins/viber.py:87 msgid "Receiver IDs" msgstr "" #: apprise/plugins/viber.py:101 #, fuzzy msgid "Bot Avatar URL" msgstr "Avatar Image" #: apprise/plugins/voipms.py:83 #, fuzzy msgid "User Email" msgstr "From Email" #: apprise/plugins/vapid/__init__.py:179 apprise/plugins/vonage.py:136 msgid "ttl" msgstr "" #: apprise/plugins/wecombot.py:99 #, fuzzy msgid "Bot Webhook Key" msgstr "Webhook Token" #: apprise/plugins/whatsapp.py:106 msgid "Template Name" msgstr "" #: apprise/plugins/whatsapp.py:112 #, fuzzy msgid "From Phone ID" msgstr "From Phone No" #: apprise/plugins/windows.py:62 msgid "A local Microsoft Windows environment is required." msgstr "" #: apprise/plugins/workflows.py:137 #, fuzzy msgid "Workflow ID" msgstr "Overflow Mode" #: apprise/plugins/workflows.py:145 msgid "Signature" msgstr "" #: apprise/plugins/workflows.py:168 msgid "Use Power Automate URL" msgstr "" #: apprise/plugins/workflows.py:175 msgid "Wrap Text" msgstr "" #: apprise/plugins/workflows.py:190 #, fuzzy msgid "API Version" msgstr "Version" #: apprise/plugins/wxpusher.py:121 #, fuzzy msgid "App Token" msgstr "Auth Token" #: apprise/plugins/wxpusher.py:133 #, fuzzy msgid "Target User ID" msgstr "Target User" #: apprise/plugins/zulip.py:148 #, fuzzy msgid "Target Stream" msgstr "Target User" #: apprise/plugins/email/base.py:150 msgid "To Email" msgstr "To Email" #: apprise/plugins/email/base.py:173 msgid "SMTP Server" msgstr "SMTP Server" #: apprise/plugins/email/base.py:178 apprise/plugins/xmpp/base.py:129 msgid "Secure Mode" msgstr "Secure Mode" #: apprise/plugins/email/base.py:190 msgid "PGP Encryption" msgstr "" #: apprise/plugins/email/base.py:196 msgid "PGP Public Key Path" msgstr "" #: apprise/plugins/fcm/__init__.py:148 msgid "OAuth2 KeyFile" msgstr "" #: apprise/plugins/fcm/__init__.py:193 msgid "Custom Image URL" msgstr "" #: apprise/plugins/fcm/__init__.py:205 msgid "Notification Color" msgstr "" #: apprise/plugins/fcm/__init__.py:215 msgid "Data Entries" msgstr "" #: apprise/plugins/irc/base.py:159 #, fuzzy msgid "Real Name" msgstr "Bot Name" #: apprise/plugins/irc/base.py:160 #, fuzzy msgid "Nickname" msgstr "Username" #: apprise/plugins/irc/base.py:162 #, fuzzy msgid "Join Channels" msgstr "Channels" #: apprise/plugins/irc/base.py:167 #, fuzzy msgid "Auth Mode" msgstr "Webhook Mode" #: apprise/plugins/vapid/__init__.py:193 msgid "PEM Private KeyFile" msgstr "" #: apprise/plugins/vapid/__init__.py:199 msgid "Subscripion File" msgstr "" #: apprise/plugins/xmpp/base.py:136 #, fuzzy msgid "Get Roster" msgstr "Target User" #: apprise/plugins/xmpp/base.py:141 msgid "Use Subject" msgstr "" #: apprise/plugins/xmpp/base.py:146 #, fuzzy msgid "Keep Connection Alive" msgstr "Server Timeout" ================================================ FILE: apprise/locale.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import ctypes import locale import os from os.path import abspath, dirname, join import re from typing import Union from .logger import logger # This gets toggled to True if we succeed GETTEXT_LOADED = False try: # Initialize gettext import gettext # Toggle our flag GETTEXT_LOADED = True except ImportError: # gettext isn't available; no problem; Use the library features without # multi-language support. pass class AppriseLocale: """A wrapper class to gettext so that we can manipulate multiple lanaguages on the fly if required.""" # Define our translation domain _domain = "apprise" # The path to our translations _locale_dir = abspath(join(dirname(__file__), "i18n")) # Locale regular expression _local_re = re.compile( r"^((?PC)|(?P([a-z]{2}))([_:](?P[a-z]{2}))?)" r"(\.(?P[a-z0-9-]+))?$", re.IGNORECASE, ) # Define our default encoding _default_encoding = "utf-8" # The function to assign `_` by default _fn = "gettext" # The language we should fall back to if all else fails _default_language = "en" def __init__(self, language=None): """Initializes our object, if a language is specified, then we initialize ourselves to that, otherwise we use whatever we detect from the local operating system. If all else fails, we resort to the defined default_language. """ # Cache previously loaded translations self._gtobjs = {} # Get our language self.lang = AppriseLocale.detect_language(language) # Our mapping to our _fn self.__fn_map = None if GETTEXT_LOADED is False: # We're done return # Add language self.add(self.lang) def add(self, lang=None, set_default=True): """Add a language to our list.""" lang = lang if lang else self._default_language if lang not in self._gtobjs: # Load our gettext object and install our language try: self._gtobjs[lang] = gettext.translation( self._domain, localedir=self._locale_dir, languages=[lang], fallback=False, ) # The non-intrusive method of applying the gettext change to # the global namespace only self.__fn_map = getattr(self._gtobjs[lang], self._fn) except FileNotFoundError: # The translation directory does not exist logger.debug( "Could not load translation path: %s", join(self._locale_dir, lang), ) # Fallback (handle case where self.lang does not exist) if self.lang not in self._gtobjs: self._gtobjs[self.lang] = gettext self.__fn_map = getattr(self._gtobjs[self.lang], self._fn) return False logger.trace("Loaded language %s", lang) if set_default: logger.debug("Language set to %s", lang) self.lang = lang return True @contextlib.contextmanager def lang_at(self, lang, mapto=_fn): """ The syntax works as: with at.lang_at('fr'): # apprise works as though the french language has been # defined. afterwards, the language falls back to whatever # it was. """ if GETTEXT_LOADED is False: # Do nothing yield None # we're done return # Tidy the language lang = AppriseLocale.detect_language(lang, detect_fallback=False) if lang not in self._gtobjs and not self.add(lang, set_default=False): # Do Nothing yield getattr(self._gtobjs[self.lang], mapto) else: # Yield yield getattr(self._gtobjs[lang], mapto) return @property def gettext(self): """Return the current language gettext() function. Useful for assigning to `_` """ return self._gtobjs[self.lang].gettext @staticmethod def detect_language(lang=None, detect_fallback=True): """Returns the language (if it's retrievable)""" # We want to only use the 2 character version of this language # hence en_CA becomes en, en_US becomes en. if not isinstance(lang, str): if detect_fallback is False: # no detection enabled; we're done return None # Posix lookup lookup = os.environ.get localename = None for variable in ("LC_ALL", "LC_CTYPE", "LANG", "LANGUAGE"): localename = lookup(variable, None) if localename: result = AppriseLocale._local_re.match(localename) if result and result.group("lang"): return result.group("lang").lower() # Windows handling if hasattr(ctypes, "windll"): windll = ctypes.windll.kernel32 try: lang = locale.windows_locale[ windll.GetUserDefaultUILanguage() ] # Our detected windows language return lang[0:2].lower() except (TypeError, KeyError): # Fallback to posix detection pass # Built in locale library check try: # Acquire our locale lang = locale.getlocale()[0] # Compatibility for Python >= 3.12 if lang == "C": lang = AppriseLocale._default_language except (ValueError, TypeError) as e: # This occurs when an invalid locale was parsed from the # environment variable. While we still return None in this # case, we want to better notify the end user of this. Users # receiving this error should check their environment # variables. logger.warning(f"Language detection failure / {e!s}") return None return None if not lang else lang[0:2].lower() def __getstate__(self): """Pickle Support dumps()""" state = self.__dict__.copy() # Remove the unpicklable entries. del state["_gtobjs"] del state["_AppriseLocale__fn_map"] return state def __setstate__(self, state): """Pickle Support loads()""" self.__dict__.update(state) # Our mapping to our _fn self.__fn_map = None self._gtobjs = {} self.add(state["lang"], set_default=True) # # Prepare our default LOCALE Singleton # LOCALE = AppriseLocale() class LazyTranslation: """Doesn't translate anything until str() or unicode() references are made.""" def __init__(self, text, *args, **kwargs): """Store our text.""" self.text = text super().__init__(*args, **kwargs) def __str__(self): return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text # Lazy translation handling def gettext_lazy(text): """A dummy function that can be referenced.""" return LazyTranslation(text=text) # Identify our Translatable content Translatable = Union[str, LazyTranslation] ================================================ FILE: apprise/logger.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib from io import StringIO import logging import os # The root identifier needed to monitor 'apprise' logging LOGGER_NAME = "apprise" # Define a verbosity level that is a noisier then debug mode logging.TRACE = logging.DEBUG - 1 # Define a verbosity level that is always used even when no verbosity is set # from the command line. The idea here is to allow for deprecation notices logging.DEPRECATE = logging.ERROR + 1 # Assign our Levels into our logging object logging.addLevelName(logging.DEPRECATE, "DEPRECATION WARNING") logging.addLevelName(logging.TRACE, "TRACE") def trace(self, message, *args, **kwargs): """ Verbose Debug Logging - Trace """ if self.isEnabledFor(logging.TRACE): self._log(logging.TRACE, message, args, **kwargs) def deprecate(self, message, *args, **kwargs): """Deprication Warning Logging.""" if self.isEnabledFor(logging.DEPRECATE): self._log(logging.DEPRECATE, message, args, **kwargs) # Assign our Loggers for use in Apprise logging.Logger.trace = trace logging.Logger.deprecate = deprecate # Create ourselve a generic (singleton) logging reference logger = logging.getLogger(LOGGER_NAME) class LogCapture: """A class used to allow one to instantiate loggers that write to memory for temporary purposes. e.g.: 1. with LogCapture() as captured: 2. 3. # Send our notification(s) 4. aobj.notify("hello world") 5. 6. # retrieve our logs produced by the above call via our 7. # `captured` StringIO object we have access to within the `with` 8. # block here: 9. print(captured.getvalue()) """ def __init__( self, path=None, level=None, name=LOGGER_NAME, delete=True, fmt="%(asctime)s - %(levelname)s - %(message)s", ): """Instantiate a temporary log capture object. If a path is specified, then log content is sent to that file instead of a StringIO object. You can optionally specify a logging level such as logging.INFO if you wish, otherwise by default the script uses whatever logging has been set globally. If you set delete to `False` then when using log files, they are not automatically cleaned up afterwards. Optionally over-ride the fmt as well if you wish. """ # Our memory buffer placeholder self.__buffer_ptr = StringIO() # Store our file path as it will determine whether or not we write to # memory and a file self.__path = path self.__delete = delete # Our logging level tracking self.__level = level self.__restore_level = None # Acquire a pointer to our logger self.__logger = logging.getLogger(name) # Prepare our handler self.__handler = ( logging.StreamHandler(self.__buffer_ptr) if not self.__path else logging.FileHandler(self.__path, mode="a", encoding="utf-8") ) # Use the specified level, otherwise take on the already # effective level of our logger self.__handler.setLevel( self.__level if self.__level is not None else self.__logger.getEffectiveLevel() ) # Prepare our formatter self.__handler.setFormatter(logging.Formatter(fmt)) def __enter__(self): """Allows logger manipulation within a 'with' block.""" if self.__level is not None: # Temporary adjust our log level if required self.__restore_level = self.__logger.getEffectiveLevel() if self.__restore_level > self.__level: # Bump our log level up for the duration of our `with` self.__logger.setLevel(self.__level) else: # No restoration required self.__restore_level = None else: # Do nothing but enforce that we have nothing to restore to self.__restore_level = None if self.__path: # If a path has been identified, ensure we can write to the path # and that the file exists with open(self.__path, "a"): os.utime(self.__path, None) # Update our buffer pointer self.__buffer_ptr = open(self.__path) # Add our handler self.__logger.addHandler(self.__handler) # return our memory pointer return self.__buffer_ptr def __exit__(self, exc_type, exc_value, tb): """Removes the handler gracefully when the with block has completed.""" # Flush our content self.__handler.flush() self.__buffer_ptr.flush() # Drop our handler self.__logger.removeHandler(self.__handler) if self.__restore_level is not None: # Restore level self.__logger.setLevel(self.__restore_level) if self.__path: # Close our file pointer self.__buffer_ptr.close() self.__handler.close() if self.__delete: with contextlib.suppress(OSError): # Always remove file afterwards os.unlink(self.__path) return exc_type is None ================================================ FILE: apprise/manager.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import contextlib import hashlib import inspect import os from os.path import abspath, dirname, join import re import sys import threading import time from .logger import logger from .utils.disk import path_decode from .utils.module import import_module from .utils.parse import parse_list from .utils.singleton import Singleton class PluginManager(metaclass=Singleton): """Designed to be a singleton object to maintain all initialized loading of modules in memory.""" # Description (used for logging) name = "Singleton Plugin" # Memory Space _id = "undefined" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result when scanning a module module_filter_re = re.compile(r"^(?P((?!_)[A-Za-z0-9]+))$") # thread safe loading _lock = threading.Lock() def __init__(self, *args, **kwargs): """Over-ride our class instantiation to provide a singleton.""" self._module_map = None self._schema_map = None # This contains a mapping of all plugins dynamicaly loaded at runtime # from external modules such as the @notify decorator # # The elements here will be additionally added to the _schema_map if # there is no conflict otherwise. # The structure looks like the following: # Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py # { # 'path': path, # # 'notify': { # 'schema': { # 'name': 'Custom schema name', # 'fn_name': 'name_of_function_decorator_was_found_on', # 'url': 'schema://any/additional/info/found/on/url' # 'plugin': # }, # 'schema2': { # 'name': 'Custom schema name', # 'fn_name': 'name_of_function_decorator_was_found_on', # 'url': 'schema://any/additional/info/found/on/url' # 'plugin': # } # } # Note: that the inherits from # NotifyBase self._custom_module_map = {} # Track manually disabled modules (by their schema) self._disabled = set() # Hash of all paths previously scanned so we don't waste # effort/overhead doing it again self._paths_previously_scanned = set() # Track loaded module paths to prevent from loading them again self._loaded = set() def unload_modules(self, disable_native=False): """Reset our object and unload all modules.""" with self._lock: if self._custom_module_map: # Handle Custom Module Assignments for meta in self._custom_module_map.values(): if meta["name"] not in self._module_map: # Nothing to remove continue # For the purpose of tidying up un-used modules in memory loaded = [ m for m in sys.modules if m.startswith(self._module_map[meta["name"]]["path"]) ] for module_path in loaded: del sys.modules[module_path] # Reset disabled plugins (if any) for schema in self._disabled: self._schema_map[schema].enabled = True self._disabled.clear() # Reset our variables self._schema_map = {} self._custom_module_map = {} if disable_native: self._module_map = {} else: self._module_map = None self._loaded = set() # Reset our path cache self._paths_previously_scanned = set() def load_modules(self, path=None, name=None, force=False): """Load our modules into memory.""" # Default value module_name_prefix = self.module_name_prefix if name is None else name module_path = self.module_path if path is None else path with self._lock: if not force and module_path in self._loaded: # We're done return # Our base reference module_count = len(self._module_map) if self._module_map else 0 schema_count = len(self._schema_map) if self._schema_map else 0 if not self: # Initialize our maps self._module_map = {} self._schema_map = {} self._custom_module_map = {} # Used for the detection of additional Notify Services objects # The .py extension is optional as we support loading directories # too module_re = re.compile( r"^(?P(?!base|_)[a-z0-9_]+)(\.py)?$", re.I ) t_start = time.time() for f in os.listdir(module_path): tl_start = time.time() match = module_re.match(f) if not match: # keep going continue # Store our notification/plugin name: module_name = match.group("name") module_pyname = f"{module_name_prefix}.{module_name}" if module_name in self._module_map: logger.warning( "%s(s) (%s) already loaded; ignoring %s", self.name, module_name, os.path.join(module_path, f), ) continue try: module = __import__( module_pyname, globals(), locals(), fromlist=[module_name], ) except ImportError: # No problem, we can try again another way... module = import_module( os.path.join(module_path, f), module_pyname ) if not module: # logging found in import_module and not needed here continue module_class = None for m_class in [ obj for obj in dir(module) if self.module_filter_re.match(obj) ]: # Get our plugin plugin = getattr(module, m_class) if not hasattr(plugin, "app_id"): # Filter out non-notification modules logger.trace( "(%s.%s) import failed; no app_id defined in %s", self.name, m_class, os.path.join(module_path, f), ) continue # Add our plugin name to our module map self._module_map[module_name] = { "plugin": {plugin}, "module": module, "path": f"{module_name_prefix}.{module_name}", "native": True, } fn = getattr(plugin, "schemas", None) schemas = set() if not callable(fn) else fn(plugin) # map our schema to our plugin for schema in schemas: if schema in self._schema_map: logger.error( f"{self.name} schema ({schema}) mismatch" " detected -" f" {self._schema_map[schema]} already maps to" f" {plugin}" ) continue # Assign plugin self._schema_map[schema] = plugin # Store our class module_class = m_class break if not module_class: # Not a library we can load as it doesn't follow the simple # rule that the class must bear the same name as the # notification file itself. logger.trace( "%s (%s) import failed; no filename/Class " "match found in %s", self.name, module_name, os.path.join(module_path, f), ) continue logger.trace( f"{self.name} {module_name} loaded in" f" {time.time() - tl_start:.6f}s" ) # Track the directory loaded so we never load it again self._loaded.add(module_path) logger.debug( f"{len(self._module_map) - module_count} {self.name}(s) and" f" {len(self._schema_map) - schema_count} Schema(s) loaded in" f" {time.time() - t_start:.4f}s" ) def module_detection(self, paths, cache=True): """Leverage the @notify decorator and load all objects found matching this.""" # A simple restriction that we don't allow periods in the filename at # all so it can't be hidden (Linux OS's) and it won't conflict with # Python path naming. This also prevents us from loading any python # file that starts with an underscore or dash # We allow for __init__.py as well module_re = re.compile( r"^(?P[_a-z0-9][a-z0-9._-]+)?(\.py)?$", re.I ) # Validate if we're a loadable Python file or not valid_python_file_re = re.compile(r".+\.py(o|c)?$", re.IGNORECASE) if isinstance(paths, str): paths = [ paths, ] if not paths or not isinstance(paths, (tuple, list)): # We're done return def _import_module(path): # Since our plugin name can conflict (as a module) with another # we want to generate random strings to avoid steping on # another's namespace if not (path and valid_python_file_re.match(path)): # Ignore file/module type logger.trace("Plugin Scan: Skipping %s", path) return t_start = time.time() module_name = hashlib.sha1(path.encode("utf-8")).hexdigest() module_pyname = "{prefix}.{name}".format( prefix="apprise.custom.module", name=module_name ) if module_pyname in self._custom_module_map: # First clear out existing entries for schema in self._custom_module_map[module_pyname]["notify"]: # Remove any mapped modules to this file del self._schema_map[schema] # Reset del self._custom_module_map[module_pyname] # Load our module module = import_module(path, module_pyname) if not module: # No problem, we can't use this object logger.warning("Failed to load custom module: %s", path_) return # Print our loaded modules if any if module_pyname in self._custom_module_map: logger.debug( "Custom module %s - %d schema(s) (name=%s) " "loaded in %.6fs", path_, len(self._custom_module_map[module_pyname]["notify"]), module_name, (time.time() - t_start), ) # Add our plugin name to our module map self._module_map[module_name] = { "plugin": set(), "module": module, "path": module_pyname, "native": False, } for schema, _meta in self._custom_module_map[module_pyname][ "notify" ].items(): # For mapping purposes; map our element in our main list self._module_map[module_name]["plugin"].add( self._schema_map[schema] ) # Log our success logger.info("Loaded custom notification: %s://", schema) else: # The code reaches here if we successfully loaded the Python # module but no hooks/triggers were found. So we can safely # just remove/ignore this entry del sys.modules[module_pyname] return # end of _import_module() return for path_ in paths: path = path_decode(path_) if ( cache and path in self._paths_previously_scanned ) or not os.path.exists(path): # We're done as we've already scanned this continue # Store our path as a way of hashing it has been handled self._paths_previously_scanned.add(path) if os.path.isdir(path) and not os.path.isfile( os.path.join(path, "__init__.py") ): logger.debug("Scanning for custom plugins in: %s", path) for entry in os.listdir(path): re_match = module_re.match(entry) if not re_match: # keep going logger.trace("Plugin Scan: Ignoring %s", entry) continue new_path = os.path.join(path, entry) if os.path.isdir(new_path): # Update our path new_path = os.path.join(path, entry, "__init__.py") if not os.path.isfile(new_path): logger.trace( "Plugin Scan: Ignoring %s", os.path.join(path, entry), ) continue if not cache or ( cache and new_path not in self._paths_previously_scanned ): # Load our module _import_module(new_path) # Add our subdir path self._paths_previously_scanned.add(new_path) else: if os.path.isdir(path): # This logic is safe to apply because we already # validated the directories state above; update our # path path = os.path.join(path, "__init__.py") if cache and path in self._paths_previously_scanned: continue self._paths_previously_scanned.add(path) # directly load as is re_match = module_re.match(os.path.basename(path)) # must be a match and must have a .py extension if not re_match or not re_match.group(1): # keep going logger.trace("Plugin Scan: Ignoring %s", path) continue # Load our module _import_module(path) return None def add(self, plugin, schemas=None, url=None, send_func=None, force=False): """Ability to manually add Notification services to our stack.""" if not self: # Lazy load self.load_modules() # Acquire a list of schemas p_schemas = parse_list(plugin.secure_protocol, plugin.protocol) if isinstance(schemas, str): schemas = [ schemas, ] elif schemas is None: # Default schemas = p_schemas if not schemas or not isinstance(schemas, (set, tuple, list)): # We're done logger.error( "The schemas provided (type %s) is unsupported; " "loaded from %s.", type(schemas), send_func.__name__ if send_func else plugin.__class__.__name__, ) return False # Convert our schemas into a set schemas = {s.lower() for s in schemas} | set(p_schemas) # Valdation conflict = [s for s in schemas if s in self] if conflict: if force: # Force implies that we unmap any conflicting schema entries # at the Apprise level, but we do not unload any previously # imported modules. This ensures other classes can safely # subclass from prior notify classes. logger.debug( "The schema(s) (%s) are already defined and will be " "force loaded; overriding %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) self.remove(*conflict, unload=False) else: logger.warning( "The schema(s) (%s) are already defined and could not be " "loaded from %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) return False # Re-check for conflicts after unmapping conflict = [s for s in schemas if s in self] if conflict: logger.warning( "The schema(s) (%s) are already defined and could not be " "loaded from %s%s.", ", ".join(conflict), "custom notify function " if send_func else "", send_func.__name__ if send_func else plugin.__class__.__name__, ) return False if send_func: # Acquire the function name fn_name = send_func.__name__ # Acquire the python filename path path = inspect.getfile(send_func) # Acquire our path to our module module_name = str(send_func.__module__) if module_name not in self._custom_module_map: # Support non-dynamic includes as well... self._custom_module_map[module_name] = { # Name can be useful for indexing back into the # _module_map object; this is the key to do it with: "name": module_name.split(".")[-1], # The path to the module loaded "path": path, # Initialize our template "notify": {}, } for schema in schemas: self._custom_module_map[module_name]["notify"][schema] = { # The name of the send function the @notify decorator # wrapped "fn_name": fn_name, # The URL that was provided in the @notify decorator call # associated with the 'on=' "url": url, } else: module_name = hashlib.sha1( "".join(schemas).encode("utf-8") ).hexdigest() module_pyname = "{prefix}.{name}".format( prefix="apprise.adhoc.module", name=module_name ) # Add our plugin name to our module map self._module_map[module_name] = { "plugin": {plugin}, "module": None, "path": module_pyname, "native": False, } for schema in schemas: # Assign our mapping self._schema_map[schema] = plugin return True def remove(self, *schemas, unload=True): """Removes a loaded element (if defined)""" if not self: # Lazy load self.load_modules() for schema in schemas: with contextlib.suppress(KeyError): self._unmap_schema(schema, unload=unload) def plugins(self, include_disabled=True): """Return all of our loaded plugins.""" if not self: # Lazy load self.load_modules() for module in self._module_map.values(): for plugin in module["plugin"]: if not include_disabled and not plugin.enabled: continue yield plugin def schemas(self, include_disabled=True): """Return all of our loaded schemas. if include_disabled == True, then even disabled notifications are returned """ if not self: # Lazy load self.load_modules() # Return our list return ( list(self._schema_map.keys()) if include_disabled else [s for s in self._schema_map if self._schema_map[s].enabled] ) def disable(self, *schemas): """Disables the modules associated with the specified schemas.""" if not self: # Lazy load self.load_modules() for schema in schemas: if schema not in self._schema_map: continue if not self._schema_map[schema].enabled: continue # Disable self._schema_map[schema].enabled = False self._disabled.add(schema) def enable_only(self, *schemas): """Disables the modules associated with the specified schemas.""" if not self: # Lazy load self.load_modules() # convert to set for faster indexing schemas = set(schemas) for plugin in self.plugins(): # Get our plugin's schema list p_schemas = set( parse_list(plugin.secure_protocol, plugin.protocol) ) if not schemas & p_schemas: if plugin.enabled: # Disable it (only if previously enabled); this prevents us # from adjusting schemas that were disabled due to missing # libraries or other environment reasons plugin.enabled = False self._disabled |= p_schemas continue # If we reach here, our schema was flagged to be enabled if p_schemas & self._disabled: # Previously disabled; no worries, let's clear this up self._disabled -= p_schemas plugin.enabled = True def __contains__(self, schema): """Checks if a schema exists.""" if not self: # Lazy load self.load_modules() return schema in self._schema_map def __delitem__(self, schema): """ removes schema map and also unloads it from memory """ self._unmap_schema(schema, unload=True) def __setitem__(self, schema, plugin): """Support fast assigning of Plugin/Notification Objects.""" if not self: # Lazy load self.load_modules() # Set default values if not otherwise set if not plugin.service_name: # Assign service name if one doesn't exist plugin.service_name = f"{schema}://" p_schemas = set(parse_list(plugin.secure_protocol, plugin.protocol)) if not p_schemas: # Assign our protocol plugin.secure_protocol = schema p_schemas.add(schema) elif schema not in p_schemas: # Add our others (if defined) plugin.secure_protocol = { schema, *parse_list(plugin.secure_protocol), } p_schemas.add(schema) if not self.add(plugin, schemas=p_schemas): raise KeyError("Conflicting Assignment") def _unmap_schema(self, schema, *, unload=True): """Unmap a schema entry without necessarily unloading modules. This function removes the schema mapping and updates internal cross references. When unload is True (default), modules are removed from sys.modules when they are no longer referenced by Apprise. When unload is False, the unmapping is performed but any imported modules remain intact in sys.modules. """ if not self: # Lazy load self.load_modules() # Get our plugin (otherwise we throw a KeyError) which is intended on # unmap action that doesn't align. plugin = self._schema_map[schema] # Our list of all schema entries p_schemas = {schema} for key in list(self._module_map.keys()): if plugin in self._module_map[key]["plugin"]: # Remove our plugin self._module_map[key]["plugin"].remove(plugin) # Custom Plugin Entry; Clean up cross reference module_pyname = self._module_map[key]["path"] if ( not self._module_map[key]["native"] and module_pyname in self._custom_module_map ): del self._custom_module_map[module_pyname][ "notify"][schema] if not self._custom_module_map[module_pyname]["notify"]: # # Last custom loaded element # # Free up custom object entry del self._custom_module_map[module_pyname] if not self._module_map[key]["plugin"]: # # Last element # if self._module_map[key]["native"]: # Get our plugin's schema list p_schemas = { s for s in parse_list( plugin.secure_protocol, plugin.protocol ) if s in self._schema_map } # Free system memory only when unload=True if unload and self._module_map[key]["module"]: with contextlib.suppress(KeyError): del sys.modules[self._module_map[key]["path"]] # Free last remaining pointer in module map del self._module_map[key] for schema in p_schemas: # Final tidy del self._schema_map[schema] def __getitem__(self, schema): """Returns the indexed plugin identified by the schema specified.""" if not self: # Lazy load self.load_modules() return self._schema_map[schema] def __iter__(self): """Returns an iterator so we can iterate over our loaded modules.""" if not self: # Lazy load self.load_modules() return iter(self._module_map.values()) def __len__(self): """Returns the number of modules/plugins loaded.""" if not self: # Lazy load self.load_modules() return len(self._module_map) def __bool__(self): """Determines if object has loaded or not.""" return bool(self._loaded and self._module_map is not None) ================================================ FILE: apprise/manager_attachment.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class AttachmentManager(PluginManager): """Designed to be a singleton object to maintain all initialized attachment plugins/modules in memory.""" # Description (used for logging) name = "Attachment Plugin" # Filename Prefix to filter on fname_prefix = "Attach" # Memory Space _id = "attachment" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base)[A-Za-z0-9]+)$" ) ================================================ FILE: apprise/manager_config.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class ConfigurationManager(PluginManager): """Designed to be a singleton object to maintain all initialized configuration plugins/modules in memory.""" # Description (used for logging) name = "Configuration Plugin" # Filename Prefix to filter on fname_prefix = "Config" # Memory Space _id = "config" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base)[A-Za-z0-9]+)$" ) ================================================ FILE: apprise/manager_plugins.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from os.path import abspath, dirname, join import re from .manager import PluginManager class NotificationManager(PluginManager): """Designed to be a singleton object to maintain all initialized notifications in memory.""" # Description (used for logging) name = "Notification Plugin" # Filename Prefix to filter on fname_prefix = "Notify" # Memory Space _id = "plugins" # Our Module Python path name module_name_prefix = f"apprise.{_id}" # The module path to scan module_path = join(abspath(dirname(__file__)), _id) # For filtering our result set module_filter_re = re.compile( r"^(?P" + fname_prefix + r"(?!Base|ImageSize|Type)[A-Za-z0-9]+)$" ) ================================================ FILE: apprise/persistent_store.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import binascii import builtins import contextlib from datetime import datetime, timedelta, timezone import glob import gzip import hashlib from itertools import chain import json import os import re import tempfile import time from typing import Any, Optional, Union import zlib from . import exception from .common import ( AWARE_DATE_ISO_FORMAT, NAIVE_DATE_ISO_FORMAT, PersistentStoreMode, ) from .logger import logger from .utils.disk import path_decode # Used for writing/reading time stored in cache file EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) def _ntf_tidy(ntf): """Reusable NamedTemporaryFile Cleanup.""" if ntf: # Cleanup with contextlib.suppress(OSError): ntf.close() try: os.unlink(ntf.name) logger.trace("Persistent temporary file removed: %s", ntf.name) except (FileNotFoundError, AttributeError): # AttributeError: something weird was passed in, no action required # FileNotFound: no worries; we were removing it anyway pass except OSError as e: logger.error( "Persistent temporary file removal failed: %s", ntf.name ) logger.debug("Persistent Storage Exception: %s", str(e)) class CacheObject: hash_engine = hashlib.sha256 hash_length = 6 def __init__( self, value: Any = None, expires: Union[bool, float, int, datetime, None] = False, persistent: bool = True, ) -> None: """Tracks our objects and associates a time limit with them.""" self.__value = value self.__class_name = value.__class__.__name__ self.__expires = None if expires: self.set_expiry(expires) # Whether or not we persist this object to disk or not self.__persistent = bool(persistent) def set( self, value: Any, expires: Union[bool, float, int, datetime, None] = None, persistent: Optional[bool] = None, ) -> None: """Sets fields on demand, if set to none, then they are left as is. The intent of set is that it allows you to set a new a value and optionally alter meta information against it. If expires or persistent isn't specified then their previous values are used. """ self.__value = value self.__class_name = value.__class__.__name__ if expires is not None: self.set_expiry(expires) if persistent is not None: self.__persistent = bool(persistent) def set_expiry(self, expires: Union[datetime, bool, float, int, None] = None) -> None: """Sets a new expiry.""" if isinstance(expires, datetime): self.__expires = expires.astimezone(timezone.utc) elif expires in (None, False): # Accepted - no expiry self.__expires = None elif expires is True: # Force expiry to now self.__expires = datetime.now(tz=timezone.utc) elif isinstance(expires, (float, int)): self.__expires = datetime.now(tz=timezone.utc) + timedelta( seconds=expires ) else: # Unsupported raise AttributeError( f"An invalid expiry time ({expires} was specified" ) def hash(self) -> str: """Our checksum to track the validity of our data.""" return self.hash_engine( str(self).encode("utf-8"), usedforsecurity=False ).hexdigest() def json(self) -> Optional[dict[str, Any]]: """Returns our preparable json object.""" return { "v": self.__value, "x": ( (self.__expires - EPOCH).total_seconds() if self.__expires else None ), "c": ( self.__class_name if not isinstance(self.__value, datetime) else ( "aware_datetime" if self.__value.tzinfo else "naive_datetime" ) ), "!": self.hash()[: self.hash_length], } @staticmethod def instantiate( content: dict[str, Any], persistent: bool = True, verify: bool = True, ) -> Optional["CacheObject"]: """Loads back data read in and returns a CacheObject or None if it could not be loaded. You can pass in the contents of CacheObject.json() and you'll receive a copy assuming the hash checks okay """ try: value = content["v"] expires = content["x"] if expires is not None: expires = datetime.fromtimestamp(expires, timezone.utc) # Acquire some useful integrity objects class_name = content.get("c", "") if not isinstance(class_name, str): raise TypeError("Class name not expected string") hashsum = content.get("!", "") if not isinstance(hashsum, str): raise TypeError("SHA1SUM not expected string") except (TypeError, KeyError) as e: logger.trace(f"CacheObject could not be parsed from {content}") logger.trace("CacheObject exception: %s", str(e)) return None if class_name in ("aware_datetime", "naive_datetime", "datetime"): # If datetime is detected, it will fall under the naive category iso_format = ( AWARE_DATE_ISO_FORMAT if class_name[0] == "a" else NAIVE_DATE_ISO_FORMAT ) try: # Python v3.6 Support value = datetime.strptime(value, iso_format) except (TypeError, ValueError): # TypeError is thrown if content is not string # ValueError is thrown if the string is not a valid format logger.trace( f"CacheObject (dt) corrupted loading from {content}" ) return None elif class_name == "bytes": try: # Convert our object back to a bytes value = base64.b64decode(value) except binascii.Error: logger.trace( f"CacheObject (bin) corrupted loading from {content}" ) return None # Initialize our object co = CacheObject(value, expires, persistent=persistent) if verify and co.hash()[: co.hash_length] != hashsum: # Our object was tampered with logger.debug(f"Tampering detected with cache entry {co}") del co return None return co @property def value(self) -> Any: """Returns our value.""" return self.__value @property def persistent(self) -> bool: """Returns our persistent value.""" return self.__persistent @property def expires(self) -> Optional[datetime]: """Returns the datetime the object will expire.""" return self.__expires @property def expires_sec(self) -> Optional[float]: """Returns the number of seconds from now the object will expire.""" return ( None if self.__expires is None else max( 0.0, ( self.__expires - datetime.now(tz=timezone.utc) ).total_seconds(), ) ) def __bool__(self) -> bool: """Returns True it the object hasn't expired, and False if it has.""" if self.__expires is None: # No Expiry return True # Calculate if we've expired or not return self.__expires > datetime.now(tz=timezone.utc) def __eq__(self, other) -> bool: """Handles equality == flag.""" if isinstance(other, CacheObject): return str(self) == str(other) return self.__value == other def __str__(self) -> str: """String output of our data.""" persistent = "+" if self.persistent else "-" return f"{self.__class_name}:{persistent}:{self.__value} expires: " + ( "never" if self.__expires is None else self.__expires.strftime(NAIVE_DATE_ISO_FORMAT) ) class CacheJSONEncoder(json.JSONEncoder): """A JSON Encoder for handling each of our cache objects.""" def default(self, entry): if isinstance(entry, datetime): return entry.strftime( AWARE_DATE_ISO_FORMAT if entry.tzinfo is not None else NAIVE_DATE_ISO_FORMAT ) elif isinstance(entry, CacheObject): return entry.json() elif isinstance(entry, bytes): return base64.b64encode(entry).decode("utf-8") return super().default(entry) class PersistentStore: """An object to make working with persistent storage easier. read() and write() are used for direct file i/o set(), get() are used for caching """ # The maximum file-size we will allow the persistent store to grow to # 1 MB = 1048576 bytes max_file_size = 1048576 # 30 days in seconds default_file_expiry = 2678400 # File encoding to use encoding = "utf-8" # Default data set base_key = "default" # Directory to store cache __cache_key = "cache" # Our Temporary working directory temp_dir = "tmp" # The directory our persistent store content gets placed in data_dir = "var" # Our Persistent Store File Extension __extension = ".psdata" # Identify our backup file extension __backup_extension = "._psbak" # Used to verify the key specified is valid # - must start with an alpha_numeric # - following optional characters can include period, underscore and # equal __valid_key = re.compile(r"[a-z0-9][a-z0-9._-]*", re.I) # Reference only __not_found_ref = (None, None) def __init__( self, path: Optional[str] = None, namespace: str = "default", mode: Optional[Union[str, PersistentStoreMode]] = None, ) -> None: """Provide the namespace to work within. namespaces can only contain alpha-numeric characters with the exception of '-' (dash), '_' (underscore), and '.' (period). The namespace must be be relative to the current URL being controlled. """ # Initalize our mode so __del__() calls don't go bad on the # error checking below self.__mode = None # Populated only once and after size() is called self.__exclude_list = None # Files to renew on calls to flush self.__renew = set() if not isinstance(namespace, str) or not self.__valid_key.match( namespace ): raise AttributeError( f"Persistent Storage namespace ({namespace}) provided is" " invalid" ) if isinstance(path, str): # A storage path has been defined if mode is None: # Store Default if no mode was provided along side of it mode = PersistentStoreMode.AUTO # Store our information self.__base_path = os.path.join(path_decode(path), namespace) self.__temp_path = os.path.join(self.__base_path, self.temp_dir) self.__data_path = os.path.join(self.__base_path, self.data_dir) else: # If no storage path is provide we set our mode to MEMORY mode = PersistentStoreMode.MEMORY self.__base_path = None self.__temp_path = None self.__data_path = None # Tracks when we have content to flush self.__dirty = False # A caching value to track persistent storage disk size self.__cache_size = None self.__cache_files = {} # Internal Cache self._cache = None try: # Store our mode self.__mode = ( mode if isinstance(mode, PersistentStoreMode) else PersistentStoreMode(mode.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid persistent storage mode ({mode}) was specified.", ) logger.warning(err) raise AttributeError(err) from None # Prepare our environment self.__prepare() def read( self, key: Optional[str] = None, compress: bool = True, expires: Union[bool, float, int] = False, ) -> Optional[bytes]: """Returns the content of the persistent store object. if refresh is set to True, then the file's modify time is updated preventing it from getting caught in prune calls. It's a means of allowing it to persist and not get cleaned up in later prune calls. Content is always returned as a byte object """ try: with self.open(key, mode="rb", compress=compress) as fd: results = fd.read(self.max_file_size) if expires is False: self.__renew.add( os.path.join( self.__data_path, f"{key}{self.__extension}" ) ) return results except (FileNotFoundError, exception.AppriseDiskIOError): # FileNotFoundError: No problem # exception.AppriseDiskIOError: # - Logging of error already occurred inside self.open() pass except (OSError, zlib.error, EOFError, UnicodeDecodeError) as e: # We can't access the file or it does not exist logger.warning("Could not read with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) # return none return None def write( self, data: Union[bytes, str, Any], key: Optional[str] = None, compress: bool = True, _recovery: bool = False, ) -> bool: """Writes the content to the persistent store if it doesn't exceed our filesize limit. Content is always written as a byte object _recovery is reserved for internal usage and should not be changed """ if key is None: key = self.base_key elif not isinstance(key, str) or not self.__valid_key.match(key): raise AttributeError( f"Persistent Storage key ({key} provided is invalid" ) if not isinstance(data, (bytes, str)): # One last check, we will accept read() objets with the expectation # it will return a binary dataset if not (hasattr(data, "read") and callable(data.read)): raise AttributeError( f"Invalid data type {type(data)} provided to Persistent" " Storage" ) try: # Read in our data data = data.read() if not isinstance(data, (bytes, str)): raise AttributeError( f"Invalid data type {type(data)} provided to" " Persistent Storage" ) except Exception as e: logger.warning( "Could read() from potential iostream with persistent " "key: %s", key, ) logger.debug("Persistent Storage Exception: %s", str(e)) raise exception.AppriseDiskIOError( f"Invalid data type {type(data)} provided to Persistent" " Storage" ) from None if self.__mode == PersistentStoreMode.MEMORY: # Nothing further can be done return False if _recovery: # Attempt to recover from a bad directory structure or setup self.__prepare() # generate our filename based on the key provided io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") # Calculate the files current filesize try: prev_size = os.stat(io_file).st_size except FileNotFoundError: # No worries, no size to accommodate prev_size = 0 except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning("Could not write with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) return False # Create a temporary file to write our content into # ntf = NamedTemporaryFile ntf = None new_file_size = 0 try: if isinstance(data, str): data = data.encode(self.encoding) ntf = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="wb", dir=self.__temp_path, delete=False ) # Close our file ntf.close() # Pointer to our open call open_ = open if not compress else gzip.open with open_(ntf.name, mode="wb") as fd: # Write our content fd.write(data) # Get our file size new_file_size = os.stat(ntf.name).st_size # Log our progress logger.trace( "Wrote %d bytes of data to persistent key: %s", new_file_size, key, ) except FileNotFoundError: # This happens if the directory path is gone preventing the file # from being created... if not _recovery: return self.write( data=data, key=key, compress=compress, _recovery=True ) # We've already made our best effort to recover if we are here in # our code base... we're going to have to exit # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except (OSError, UnicodeEncodeError, zlib.error) as e: # We can't access the file or it does not exist logger.warning("Could not write to persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) return False if ( self.max_file_size > 0 and (new_file_size + self.size() - prev_size) > self.max_file_size ): # The content to store is to large logger.warning( "Persistent content exceeds allowable maximum file length" f" ({int(self.max_file_size / 1024)}KB); provide" f" {int(new_file_size / 1024)}KB" ) return False # Return our final move if not self.__move(ntf.name, io_file): # Attempt to restore things as they were # Tidy our Named Temporary File _ntf_tidy(ntf) return False # Resetour reference variables self.__cache_size = None self.__cache_files.clear() # Content installed return True def __move(self, src, dst): """Moves the new file in place and handles the old if it exists already If the transaction fails in any way, the old file is swapped back. Function returns True if successful and False if not. """ # A temporary backup of the file we want to move in place dst_backup = ( dst[: -len(self.__backup_extension)] + self.__backup_extension ) # # Backup the old file (if it exists) allowing us to have a restore # point in the event of a failure # try: # make sure the file isn't already present; if it is; remove it os.unlink(dst_backup) logger.trace( "Removed previous persistent backup file: %s", dst_backup ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not previous persistent data backup: %s", dst_backup ) logger.debug("Persistent Storage Exception: %s", str(e)) return False try: # Back our file up so we have a fallback os.rename(dst, dst_backup) logger.trace( "Persistent storage backup file created: %s", dst_backup ) except FileNotFoundError: # Not a problem; this is a brand new file we're writing # There is nothing to backup pass except OSError as e: # This isn't good... we couldn't put our new file in place logger.warning( "Could not install persistent content %s -> %s", dst, os.path.basename(dst_backup), ) logger.debug("Persistent Storage Exception: %s", str(e)) return False # # Now place the new file # try: os.rename(src, dst) logger.trace("Persistent file installed: %s", dst) except OSError as e: # This isn't good... we couldn't put our new file in place # Begin fall-back process before leaving the funtion logger.warning( "Could not install persistent content %s -> %s", src, os.path.basename(dst), ) logger.debug("Persistent Storage Exception: %s", str(e)) try: # Restore our old backup (if it exists) os.rename(dst_backup, dst) logger.trace("Restoring original persistent content: %s", dst) except FileNotFoundError: # Not a problem pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Failed to restore original persistent file: %s", dst ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return True def open( self, key: Optional[str] = None, mode: str = "r", buffering: int = -1, encoding: Optional[str] = None, errors: Optional[str] = None, newline: Optional[str] = None, closefd: bool = True, opener: Optional[Any] = None, compress: bool = False, compresslevel: int = 9, ) -> Any: """Returns an iterator to our our file within our namespace identified by the key provided. If no key is provided, then the default is used """ if key is None: key = self.base_key elif not isinstance(key, str) or not self.__valid_key.match(key): raise AttributeError( f"Persistent Storage key ({key} provided is invalid" ) if self.__mode == PersistentStoreMode.MEMORY: # Nothing further can be done raise FileNotFoundError() io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") try: return ( open( io_file, mode=mode, buffering=buffering, encoding=encoding, errors=errors, newline=newline, closefd=closefd, opener=opener, ) if not compress else gzip.open( io_file, compresslevel=compresslevel, encoding=encoding, errors=errors, newline=newline, ) ) except FileNotFoundError: # pass along (but wrap with Apprise exception) raise exception.AppriseFileNotFound( f"No such file or directory: '{io_file}'" ) from None except (OSError, zlib.error) as e: # We can't access the file or it does not exist logger.warning("Could not read with persistent key: %s", key) logger.debug("Persistent Storage Exception: %s", str(e)) raise exception.AppriseDiskIOError(str(e)) from None def get( self, key: str, default: Any = None, lazy: bool = True, ) -> Any: """Fetches from cache.""" if self._cache is None and not self.__load_cache(): return default if ( key in self._cache and self.__mode != PersistentStoreMode.MEMORY and not self.__dirty ): # ensure we renew our content self.__renew.add(self.cache_file) return self._cache[key].value if self._cache.get(key) else default def set( self, key: str, value: Any, expires: Union[float, int, datetime, bool, None] = None, persistent: bool = True, lazy: bool = True, ) -> bool: """Cache reference.""" if self._cache is None and not self.__load_cache(): return False cache = CacheObject(value, expires, persistent=persistent) # Fetch our cache value try: if lazy and cache == self._cache[key]: # We're done; nothing further to do return True except KeyError: pass # Store our new cache self._cache[key] = CacheObject(value, expires, persistent=persistent) # Set our dirty flag self.__dirty = persistent if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() return True def clear(self, *args: str) -> Optional[bool]: """Remove one or more cache entry by it's key. e.g: clear('key') clear('key1', 'key2', key-12') Or clear everything: clear() """ if self._cache is None and not self.__load_cache(): return False if args: for arg in args: try: del self._cache[arg] # Set our dirty flag (if not set already) self.__dirty = True except KeyError: pass elif self._cache: # Request to remove everything and there is something to remove # Set our dirty flag (if not set already) self.__dirty = True # Reset our object self._cache.clear() if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() def prune(self) -> bool: """Eliminates expired cache entries.""" if self._cache is None and not self.__load_cache(): return False change = False for key in list(self._cache.keys()): if key not in self: # It's identified as being expired if not change and self._cache[key].persistent: # track change only if content was persistent change = True # Set our dirty flag self.__dirty = True del self._cache[key] if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk return self.flush() return change def __load_cache(self, _recovery=False): """Loads our cache. _recovery is reserved for internal usage and should not be changed """ # Prepare our dirty flag self.__dirty = False if self.__mode == PersistentStoreMode.MEMORY: # Nothing further to do self._cache = {} return True # Prepare our cache file cache_file = self.cache_file try: with gzip.open(cache_file, "rb") as f: # Read our ontent from disk self._cache = {} for k, v in json.loads(f.read().decode(self.encoding)).items(): co = CacheObject.instantiate(v) if co: # Verify our object before assigning it self._cache[k] = co elif not self.__dirty: # Track changes from our loadset self.__dirty = True except ( UnicodeDecodeError, json.decoder.JSONDecodeError, zlib.error, TypeError, AttributeError, EOFError, ): # Let users known there was a problem logger.warning( "Corrupted access persistent cache content: %s", cache_file ) if not _recovery: try: os.unlink(cache_file) logger.trace( "Removed previous persistent cache content: %s", cache_file, ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not remove persistent cache content: %s", cache_file, ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return self.__load_cache(_recovery=True) return False except FileNotFoundError: # No problem; no cache to load self._cache = {} except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not load persistent cache for namespace %s", os.path.basename(self.__base_path), ) logger.debug("Persistent Storage Exception: %s", str(e)) return False # Ensure our dirty flag is set to False return True def __prepare(self, flush=True): """Prepares a working environment.""" if self.__mode != PersistentStoreMode.MEMORY: # Ensure our path exists try: os.makedirs(self.__base_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__base_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY # Ensure our path exists try: os.makedirs(self.__temp_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__temp_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY try: os.makedirs(self.__data_path, mode=0o770, exist_ok=True) except OSError as e: # Permission error logger.debug( "Could not create persistent store directory %s", self.__data_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Mode changed back to MEMORY self.__mode = PersistentStoreMode.MEMORY if self.__mode is PersistentStoreMode.MEMORY: logger.warning( "The persistent storage could not be fully initialized; " "operating in MEMORY mode" ) else: if self._cache: # Recovery taking place self.__dirty = True logger.warning( "The persistent storage environment was disrupted" ) if self.__mode is PersistentStoreMode.FLUSH and flush: # Flush changes to disk return self.flush(_recovery=True) def flush( self, force: bool = False, _recovery: bool = False, ) -> bool: """Save's our cache to disk.""" if self._cache is None or self.__mode == PersistentStoreMode.MEMORY: # nothing to do return True while self.__renew: # update our files path = self.__renew.pop() ftime = time.time() try: # (access_time, modify_time) os.utime(path, (ftime, ftime)) logger.trace("file timestamp updated: %s", path) except FileNotFoundError: # No worries... move along pass except OSError as e: # We can't access the file or it does not exist logger.debug("Could not update file timestamp: %s", path) logger.debug("Persistent Storage Exception: %s", str(e)) if not force and self.__dirty is False: # Nothing further to do logger.trace("Persistent cache is consistent with memory map") return True if _recovery: # Attempt to recover from a bad directory structure or setup self.__prepare(flush=False) # Unset our size lazy setting self.__cache_size = None self.__cache_files.clear() # Prepare our cache file cache_file = self.cache_file if not self._cache: # # We're deleting the cache file s there are no entries left in it # backup_file = ( cache_file[: -len(self.__backup_extension)] + self.__backup_extension ) try: os.unlink(backup_file) logger.trace( "Removed previous persistent cache backup: %s", backup_file ) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.warning( "Could not remove persistent cache backup: %s", backup_file ) logger.debug("Persistent Storage Exception: %s", str(e)) return False try: os.rename(cache_file, backup_file) logger.trace( "Persistent cache backup file created: %s", backup_file ) except FileNotFoundError: # Not a problem; do not create a log entry pass except OSError as e: # This isn't good... we couldn't put our new file in place logger.warning( "Could not remove stale persistent cache file: %s", cache_file, ) logger.debug("Persistent Storage Exception: %s", str(e)) return False return True # # If we get here, we need to update our file based cache # # ntf = NamedTemporaryFile ntf = None try: ntf = tempfile.NamedTemporaryFile( # noqa: SIM115 mode="w+", encoding=self.encoding, dir=self.__temp_path, delete=False, ) ntf.close() except FileNotFoundError: # This happens if the directory path is gone preventing the file # from being created... if not _recovery: return self.flush(force=True, _recovery=True) # We've already made our best effort to recover if we are here in # our code base... we're going to have to exit # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except OSError as e: logger.error( "Persistent temporary directory inaccessible: %s", self.__temp_path, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False try: # write our content currently saved to disk to our temporary file with gzip.open(ntf.name, "wb") as f: # Write our content to disk f.write( json.dumps( { k: v for k, v in self._cache.items() if v and v.persistent }, separators=(",", ":"), cls=CacheJSONEncoder, ).encode(self.encoding) ) except TypeError as e: # JSON object contains content that can not be encoded to disk logger.error( "Persistent temporary file can not be written to " "due to bad input data: %s", ntf.name, ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False except (OSError, EOFError, zlib.error) as e: logger.error( "Persistent temporary file inaccessible: %s", ntf.name ) logger.debug("Persistent Storage Exception: %s", str(e)) # Tidy our Named Temporary File _ntf_tidy(ntf) # Early Exit return False if not self.__move(ntf.name, cache_file): # Attempt to restore things as they were # Tidy our Named Temporary File _ntf_tidy(ntf) return False # Ensure our dirty flag is set to False self.__dirty = False return True def files( self, exclude: bool = True, lazy: bool = True, ) -> list[str]: """Returns the total files.""" if lazy and exclude in self.__cache_files: # Take an early exit with our cached results return self.__cache_files[exclude] elif self.__mode == PersistentStoreMode.MEMORY: # Take an early exit # exclude is our cache switch and can be either True or False. # For the below, we just set both cases and set them up as an # empty record self.__cache_files.update({True: [], False: []}) return [] if not lazy or self.__exclude_list is None: # A list of criteria that should be excluded from the size count self.__exclude_list = ( # Exclude backup cache file from count re.compile( re.escape( os.path.join( self.__base_path, f"{self.__cache_key}{self.__backup_extension}", ) ) ), # Exclude temporary files re.compile(re.escape(self.__temp_path) + r"[/\\].+"), # Exclude custom backup persistent files re.compile( re.escape(self.__data_path) + r"[/\\].+" + re.escape(self.__backup_extension) ), ) try: if exclude: self.__cache_files[exclude] = [ path for path in filter( os.path.isfile, glob.glob( os.path.join(self.__base_path, "**", "*"), recursive=True, ), ) if next( (False for p in self.__exclude_list if p.match(path)), True, ) ] else: # No exclusion list applied self.__cache_files[exclude] = list( filter( os.path.isfile, glob.glob( os.path.join(self.__base_path, "**", "*"), recursive=True, ), ) ) except OSError: # We can't access the directory or it does not exist self.__cache_files[exclude] = [] return self.__cache_files[exclude] @staticmethod def disk_scan( path: str, namespace: Optional[Union[str, list[str]]] = None, closest: bool = True, ) -> list[str]: """Scansk a path provided and returns namespaces detected.""" logger.trace("Persistent path can of: %s", path) def is_namespace(x): """Validate what was detected is a valid namespace.""" return os.path.isdir( os.path.join(path, x) ) and PersistentStore.__valid_key.match(x) # Handle our namespace searching if namespace: if isinstance(namespace, str): namespace = [namespace] elif not isinstance(namespace, (tuple, set, list)): raise AttributeError( "namespace must be None, a string, or a tuple/set/list " "of strings" ) try: # Acquire all of the files in question namespaces = ( [ ns for ns in filter(is_namespace, os.listdir(path)) if not namespace or next( (True for n in namespace if ns.startswith(n)), False ) ] if closest else [ ns for ns in filter(is_namespace, os.listdir(path)) if not namespace or ns in namespace ] ) except FileNotFoundError: # no worries; Nothing to do logger.debug("Disk Prune path not found; nothing to clean.") return [] except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error("Disk Scan detetcted inaccessible path: %s", path) logger.debug("Persistent Storage Exception: %s", str(e)) return [] return namespaces @staticmethod def disk_prune( path: str, namespace: Optional[Union[str, list[str]]] = None, expires: Optional[Union[int, float]] = None, action: bool = False, ) -> dict[str, list[dict[str, Union[str, bool]]]]: """Prune persistent disk storage entries that are old and/or unreferenced. you must specify a path to perform the prune within if one or more namespaces are provided, then pruning focuses ONLY on those entries (if matched). if action is not set to False, directories to be removed are returned only """ # Prepare our File Expiry expires = ( datetime.now() - timedelta(seconds=expires) if isinstance(expires, (float, int)) and expires >= 0 else PersistentStore.default_file_expiry ) # Get our namespaces namespaces = PersistentStore.disk_scan(path, namespace) # Track matches map_ = {} for namespace in namespaces: # Prepare our map map_[namespace] = [] # Reference Directories base_dir = os.path.join(path, namespace) data_dir = os.path.join(base_dir, PersistentStore.data_dir) temp_dir = os.path.join(base_dir, PersistentStore.temp_dir) # Careful to only focus on files created by this Persistent Store # object files = [ os.path.join( base_dir, f"{PersistentStore.__cache_key}" f"{PersistentStore.__extension}", ), os.path.join( base_dir, f"{PersistentStore.__cache_key}" f"{PersistentStore.__backup_extension}", ), ] # Update our files (applying what was defined above too) valid_data_re = re.compile( r".*(" + re.escape(PersistentStore.__extension) + r"|" + re.escape(PersistentStore.__backup_extension) + r")$" ) files = [ path for path in filter( os.path.isfile, chain( glob.glob( os.path.join(data_dir, "*"), recursive=False ), files, ), ) if valid_data_re.match(path) ] # Now all temporary files files.extend( list( filter( os.path.isfile, glob.glob( os.path.join(temp_dir, "*"), recursive=False ), ) ) ) # Track if we should do a directory sweep later on dir_sweep = True # Scan our files for file in files: try: mtime = datetime.fromtimestamp(os.path.getmtime(file)) except FileNotFoundError: # no worries; we were removing it anyway continue except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error( "Disk Prune (ns=%s, clean=%s) detetcted inaccessible " "file: %s", namespace, "yes" if action else "no", file, ) logger.debug("Persistent Storage Exception: %s", str(e)) # No longer worth doing a directory sweep dir_sweep = False continue if expires < mtime: continue # # Handle Removing # record = { "path": file, "removed": False, } if action: try: os.unlink(file) # Update our record record["removed"] = True logger.info( "Disk Prune (ns=%s, clean=%s) removed persistent " "file: %s", namespace, "yes" if action else "no", file, ) except FileNotFoundError: # no longer worth doing a directory sweep dir_sweep = False # otherwise, no worries; we were removing the file # anyway except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point logger.error( "Disk Prune (ns=%s, clean=%s) failed to remove " "persistent file: %s", namespace, "yes" if action else "no", file, ) logger.debug( "Persistent Storage Exception: %s", str(e) ) # No longer worth doing a directory sweep dir_sweep = False # Store our record map_[namespace].append(record) # Memory tidy del files if dir_sweep: # Gracefully cleanup our namespace directory. It's okay if we # fail; This just means there were files in the directory. for dirpath in (temp_dir, data_dir, base_dir): if action: try: os.rmdir(dirpath) logger.info( "Disk Prune (ns=%s, clean=%s) removed " "persistent dir: %s", namespace, "yes" if action else "no", dirpath, ) except OSError: # do nothing; pass return map_ def size( self, exclude: bool = True, lazy: bool = True, ) -> int: """Returns the total size of the persistent storage in bytes.""" if lazy and self.__cache_size is not None: # Take an early exit return self.__cache_size elif self.__mode == PersistentStoreMode.MEMORY: # Take an early exit self.__cache_size = 0 return self.__cache_size # Get a list of files (file paths) in the given directory try: self.__cache_size = sum(os.stat(path).st_size for path in self.files(exclude=exclude, lazy=lazy)) except OSError: # We can't access the directory or it does not exist self.__cache_size = 0 return self.__cache_size def __del__(self) -> None: """Deconstruction of our object.""" if self.__mode == PersistentStoreMode.AUTO: # Flush changes to disk self.flush() def __delitem__(self, key: str) -> None: """Remove a cache entry by it's key.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") try: if self._cache[key].persistent: # Set our dirty flag in advance self.__dirty = True # Store our new cache del self._cache[key] except KeyError: # Nothing to do raise if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk self.flush() return def __contains__(self, key: str) -> bool: """Verify if our storage contains the key specified or not. In additiont to this, if the content is expired, it is considered to be not contained in the storage. """ if self._cache is None and not self.__load_cache(): return False return key in self._cache and self._cache[key] def __setitem__(self, key: str, value: Any) -> None: """Sets a cache value without disrupting existing settings in place.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") if key not in self._cache and not self.set(key, value): raise KeyError("Could not set cache") else: # Update our value self._cache[key].set(value) if self._cache[key].persistent: # Set our dirty flag in advance self.__dirty = True if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: # Flush changes to disk self.flush() return def __getitem__(self, key: str) -> Any: """Returns the indexed value.""" if self._cache is None and not self.__load_cache(): raise KeyError("Could not initialize cache") result = self.get(key, default=self.__not_found_ref, lazy=False) if result is self.__not_found_ref: raise KeyError(f" {key} not found in cache") return result def keys(self) -> builtins.set[str]: """Returns our keys.""" if self._cache is None and not self.__load_cache(): # There are no keys to return return {}.keys() return self._cache.keys() def delete( self, *args: str, all: Optional[bool] = None, temp: Optional[bool] = None, cache: Optional[bool] = None, validate: bool = True, ) -> bool: """Manages our file space and tidys it up. delete('key', 'key2') delete(all=True) delete(temp=True, cache=True) """ # Our failure flag has_error = False valid_key_re = re.compile( r"^(?P.+)(" + re.escape(self.__backup_extension) + r"|" + re.escape(self.__extension) + r")$", re.I, ) # Default asignments if all is None: all = bool(not (len(args) or temp or cache)) if temp is None: temp = bool(all) if cache is None: cache = bool(all) if cache and self._cache: # Reset our object self._cache.clear() # Reset dirt flag self.__dirty = False for path in self.files(exclude=False): # Some information we use to validate the actions of our clean() # call. This is so we don't remove anything we shouldn't base = os.path.dirname(path) fname = os.path.basename(path) # Clean printable path details ppath = os.path.join(os.path.dirname(base), fname) if base == self.__base_path and cache: # We're handling a cache file (hopefully) result = valid_key_re.match(fname) key = ( None if not result else ( result["key"] if self.__valid_key.match(result["key"]) else None ) ) if validate and key != self.__cache_key: # We're not dealing with a cache key logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue # # We should proceed with removing the file if we get here # elif base == self.__data_path and (args or all): # We're handling a file found in our custom data path result = valid_key_re.match(fname) key = ( None if not result else ( result["key"] if self.__valid_key.match(result["key"]) else None ) ) if validate and key is None: # we're set to validate and a non-valid file was found logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue elif not all and (key is None or key not in args): # no match found logger.debug( "Persistent File cleanup ignoring file: %s", path ) continue # # We should proceed with removing the file if we get here # elif base == self.__temp_path and temp: # # This directory is a temporary path and nothing in here needs # to be further verified. Proceed with the removing of the file # pass else: # No match; move on logger.debug("Persistent File cleanup ignoring file: %s", path) continue try: os.unlink(path) logger.info("Removed persistent file: %s", ppath) except FileNotFoundError: # no worries; we were removing it anyway pass except OSError as e: # Permission error of some kind or disk problem... # There is nothing we can do at this point has_error = True logger.error("Failed to remove persistent file: %s", ppath) logger.debug("Persistent Storage Exception: %s", str(e)) # Reset our reference variables self.__cache_size = None self.__cache_files.clear() return not has_error @property def cache_file(self) -> str: """Returns the full path to the namespace directory.""" return os.path.join( self.__base_path, f"{self.__cache_key}{self.__extension}", ) @property def path(self) -> Optional[str]: """Returns the full path to the namespace directory.""" return self.__base_path @property def mode(self) -> PersistentStoreMode: """Returns the Persistent Storage mode.""" return self.__mode ================================================ FILE: apprise/plugins/__init__.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import copy import os from ..common import ( NOTIFY_IMAGE_SIZES, NOTIFY_TYPES, NotifyImageSize, NotifyType, ) from ..locale import LazyTranslation, gettext_lazy as _ from ..logger import logger from ..manager_plugins import NotificationManager from ..utils.cwe312 import cwe312_url from ..utils.parse import GET_SCHEMA_RE, parse_list # Used for testing from .base import NotifyBase # Grant access to our Notification Manager Singleton N_MGR = NotificationManager() __all__ = [ "NOTIFY_IMAGE_SIZES", "NOTIFY_TYPES", "NotifyBase", # Reference "NotifyImageSize", "NotifyType", # Tokenizer "url_to_dict", ] def _sanitize_token(tokens, default_delimiter): """This is called by the details() function and santizes the output by populating expected and consistent arguments if they weren't otherwise specified.""" # Used for tracking groups group_map = {} # Iterate over our tokens for key in tokens: for element in tokens[key]: # Perform translations (if detected to do so) if isinstance(tokens[key][element], LazyTranslation): tokens[key][element] = str(tokens[key][element]) if "alias_of" in tokens[key]: # Do not touch this field continue elif "name" not in tokens[key]: # Default to key tokens[key]["name"] = key if "map_to" not in tokens[key]: # Default type to key tokens[key]["map_to"] = key # Track our map_to objects if tokens[key]["map_to"] not in group_map: group_map[tokens[key]["map_to"]] = set() group_map[tokens[key]["map_to"]].add(key) if "type" not in tokens[key]: # Default type to string tokens[key]["type"] = "string" elif tokens[key]["type"].startswith("list"): if "delim" not in tokens[key]: # Default list delimiter (if not otherwise specified) tokens[key]["delim"] = default_delimiter if key in group_map[tokens[key]["map_to"]]: # pragma: no branch # Remove ourselves from the list group_map[tokens[key]["map_to"]].remove(key) # Pointing to the set directly so we can dynamically update # ourselves tokens[key]["group"] = group_map[tokens[key]["map_to"]] elif ( tokens[key]["type"].startswith("choice") and "default" not in tokens[key] and "values" in tokens[key] and len(tokens[key]["values"]) == 1 ): # If there is only one choice; then make it the default # - support dictionaries too tokens[key]["default"] = ( tokens[key]["values"][0] if not isinstance(tokens[key]["values"], dict) else next(iter(tokens[key]["values"])) ) if "values" in tokens[key] and isinstance(tokens[key]["values"], dict): # Convert values into a list if it was defined as a dictionary tokens[key]["values"] = list(tokens[key]["values"].keys()) if "regex" in tokens[key]: # Verify that we are a tuple; convert strings to tuples if isinstance(tokens[key]["regex"], str): # Default tuple setup tokens[key]["regex"] = (tokens[key]["regex"], None) elif not isinstance(tokens[key]["regex"], (list, tuple)): # Invalid regex del tokens[key]["regex"] if "required" not in tokens[key]: # Default required is False tokens[key]["required"] = False if "private" not in tokens[key]: # Private flag defaults to False if not set tokens[key]["private"] = False return def details(plugin): """Provides templates that can be used by developers to build URLs dynamically. If a list of templates is provided, then they will be used over the default value. If a list of tokens are provided, then they will over-ride any additional settings built from this script and/or will be appended to them afterwards. """ # Our unique list of parsing will be based on the provided templates # if none are provided we will use our own templates = tuple(plugin.templates) # The syntax is simple # { # # The token_name must tie back to an entry found in the # # templates list. # 'token_name': { # # # types can be 'string', 'int', 'choice', 'list, 'float' # # both choice and list may additionally have a : identify # # what the list/choice type is comprised of; the default # # is string. # 'type': 'choice:string', # # # values will only exist the type must be a fixed # # list of inputs (generated from type choice for example) # # # If this is a choice:bool then you should ALWAYS define # # this list as a (True, False) such as ('Yes, 'No') or # # ('Enabled', 'Disabled'), etc # 'values': [ 'http', 'https' ], # # # Identifies if the entry specified is required or not # 'required': True, # # # Identifies all tokens detected to be associated with the # # list:string # # This is ony present in list:string objects and is only set # # if this element acts as an alias for several other # # kwargs/fields. # 'group': [], # # # Identify a default value # 'default': 'http', # # # Optional Verification Entries min and max are for floats # # and/or integers # 'min': 4, # 'max': 5, # # # A list will always identify a delimiter. If this is # # part of a path, this may be a '/', or it could be a # # comma and/or space. delimiters are always in a list # # eg (if space and/or comma is a delimiter the entry # # would look like: 'delim': [',' , ' ' ] # 'delim': None, # # # Use regex if you want to share the regular expression # # required to validate the field. The regex will never # # accommodate the prefix (if one is specified). That is # # up to the user building the URLs to include the prefix # # on the URL when constructing it. # # The format is ('regex', 'reg options') # 'regex': (r'[A-Z0-9]+', 'i'), # # # A Prefix is always a string, to differentiate between # # multiple arguments, sometimes content is prefixed. # 'prefix': '@', # # # By default the key of this object is to be interpreted # # as the argument to the notification in question. However # # To accommodate cases where there are multiple types that # # all map to the same entry, one can find a map_to value. # 'map_to': 'function_arg', # # # Some arguments act as an alias_of an already defined object # # This plays a role more with configuration file generation # # since yaml files allow you to define different argumuments # # in line to simplify things. If this directive is set, then # # it should be treated exactly the same as the object it is # # an alias of # 'alias_of': 'function_arg', # # # Advise developers to consider the potential sensitivity # # of this field owned by the user. This is for passwords, # # and api keys, etc... # 'private': False, # }, # } # Template tokens identify the arguments required to initialize the # plugin itself. It identifies all of the tokens and provides some # details on their use. Each token defined should in some way map # back to at least one URL {token} defined in the templates # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_tokens = copy.deepcopy(plugin.template_tokens) # Arguments and/or Options either have a default value and/or are # optional to be set. # # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_args = copy.deepcopy(plugin.template_args) # Our template keyword arguments ?+key=value&-key=value # Basically the user provides both the key and the value. this is only # possibly by identifying the key prefix required for them to be # interpreted hence the +/- keys are built into apprise by default for easy # reference. In these cases, entry might look like '+' being the prefix: # { # 'arg_name': { # 'name': 'label', # 'prefix': '+', # } # } # # Since we nest a dictionary within a dictionary, a simple copy isn't # enough. a deepcopy allows us to manipulate this object in this # funtion without obstructing the original. template_kwargs = copy.deepcopy(plugin.template_kwargs) # We automatically create a schema entry template_tokens["schema"] = { "name": _("Schema"), "type": "choice:string", "required": True, "values": parse_list(plugin.secure_protocol, plugin.protocol), } # Sanitize our tokens _sanitize_token(template_tokens, default_delimiter=("/",)) # Delimiter(s) are space and/or comma _sanitize_token(template_args, default_delimiter=(",", " ")) _sanitize_token(template_kwargs, default_delimiter=(",", " ")) # Argument/Option Handling for key in list(template_args.keys()): if "alias_of" in template_args[key]: # Check if the mapped reference is a list; if it is, then # we need to store a different delimiter alias_of = template_tokens.get(template_args[key]["alias_of"], {}) if ( alias_of.get("type", "").startswith("list") and "delim" not in template_args[key] ): # Set a default delimiter of a comma and/or space if one # hasn't already been specified template_args[key]["delim"] = (",", " ") # _lookup_default looks up what the default value if "_lookup_default" in template_args[key]: template_args[key]["default"] = getattr( plugin, template_args[key]["_lookup_default"] ) # Tidy as we don't want to pass this along in response del template_args[key]["_lookup_default"] # _exists_if causes the argument to only exist IF after checking # the return of an internal variable requiring a check if "_exists_if" in template_args[key]: if not getattr(plugin, template_args[key]["_exists_if"]): # Remove entire object del template_args[key] else: # We only nee to remove this key del template_args[key]["_exists_if"] return { "templates": templates, "tokens": template_tokens, "args": template_args, "kwargs": template_kwargs, } def requirements(plugin): """Provides a list of packages and its requirement details.""" requirements = { # Use the description to provide a human interpretable description of # what is required to make the plugin work. This is only nessisary # if there are package dependencies "details": "", # Define any required packages needed for the plugin to run. This is # an array of strings that simply look like lines in the # `requirements.txt` file... # # A single string is perfectly acceptable: # 'packages_required' = 'cryptography' # # Multiple entries should look like the following # 'packages_required' = [ # 'cryptography < 3.4`, # ] # "packages_required": [], # Recommended packages identify packages that are not required to make # your plugin work, but would improve it's use or grant it access to # full functionality (that might otherwise be limited). # Similar to `packages_required`, you would identify each entry in # the array as you would in a `requirements.txt` file. # # - Do not re-provide entries already in the `packages_required` "packages_recommended": [], } # Populate our template differently if we don't find anything above if not ( hasattr(plugin, "requirements") and isinstance(plugin.requirements, dict) ): # We're done early return requirements # Get our required packages req_packages = plugin.requirements.get("packages_required") if isinstance(req_packages, str): # Convert to list req_packages = [req_packages] elif not isinstance(req_packages, (set, list, tuple)): # Allow one to set the required packages to None (as an example) req_packages = [] requirements["packages_required"] = [str(p) for p in req_packages] # Get our recommended packages opt_packages = plugin.requirements.get("packages_recommended") if isinstance(opt_packages, str): # Convert to list opt_packages = [opt_packages] elif not isinstance(opt_packages, (set, list, tuple)): # Allow one to set the recommended packages to None (as an example) opt_packages = [] requirements["packages_recommended"] = [str(p) for p in opt_packages] # Get our package details req_details = plugin.requirements.get("details") if not req_details: if not (req_packages or opt_packages): req_details = _("No dependencies.") elif req_packages: req_details = _("Packages are required to function.") else: # opt_packages req_details = _( "Packages are recommended to improve functionality." ) else: # Store our details if defined requirements["details"] = req_details # Return our compiled package requirements return requirements def url_to_dict(url, secure_logging=True): """Takes an apprise URL and returns the tokens associated with it if they can be acquired based on the plugins available. None is returned if the URL could not be parsed, otherwise the tokens are returned. These tokens can be loaded into apprise through it's add() function. """ # swap hash (#) tag values with their html version url_ = url.replace("/#", "/%23") # CWE-312 (Secure Logging) Handling loggable_url = url if not secure_logging else cwe312_url(url) # Attempt to acquire the schema at the very least to allow our plugins to # determine if they can make a better interpretation of a URL geared for # them. schema = GET_SCHEMA_RE.match(url_) if schema is None: # Not a valid URL; take an early exit logger.error(f"Unsupported URL: {loggable_url}") return None # Ensure our schema is always in lower case schema = schema.group("schema").lower() if schema not in N_MGR: # Give the user the benefit of the doubt that the user may be using # one of the URLs provided to them by their notification service. # Before we fail for good, just scan all the plugins that support the # native_url() parse function results = None for plugin in N_MGR.plugins(): results = plugin.parse_native_url(url_) if results: break if not results: logger.error(f"Unparseable URL {loggable_url}") return None logger.trace( "URL {} unpacked as:{}{}".format( url, os.linesep, os.linesep.join([f'{k}="{v}"' for k, v in results.items()]), ) ) else: # Parse our url details of the server object as dictionary # containing all of the information parsed from our URL results = N_MGR[schema].parse_url(url_) if not results: logger.error( f"Unparseable {N_MGR[schema].service_name} URL {loggable_url}" ) return None logger.trace( "{} URL {} unpacked as:{}{}".format( N_MGR[schema].service_name, url, os.linesep, os.linesep.join([f'{k}="{v}"' for k, v in results.items()]), ) ) # Return our results return results ================================================ FILE: apprise/plugins/africas_talking.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, you must have a Africas Talking Account setup; See here: # https://account.africastalking.com/ # From here... acquire your APIKey # # API Details: https://developers.africastalking.com/docs/sms/sending/bulk import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase class AfricasTalkingSMSMode: """Africas Talking SMS Mode.""" # BulkSMS Mode BULKSMS = "bulksms" # Premium Mode PREMIUM = "premium" # Sandbox Mode SANDBOX = "sandbox" # Define the types in a list for validation purposes AFRICAS_TALKING_SMS_MODES = ( AfricasTalkingSMSMode.BULKSMS, AfricasTalkingSMSMode.PREMIUM, AfricasTalkingSMSMode.SANDBOX, ) # Extend HTTP Error Messages AFRICAS_TALKING_HTTP_ERROR_MAP = { 100: "Processed", 101: "Sent", 102: "Queued", 401: "Risk Hold", 402: "Invalid Sender ID", 403: "Invalid Phone Number", 404: "Unsupported Number Type", 405: "Insufficient Balance", 406: "User In Blacklist", 407: "Could Not Route", 409: "Do Not Disturb Rejection", 500: "Internal Server Error", 501: "Gateway Error", 502: "Rejected By Gateway", } class NotifyAfricasTalking(NotifyBase): """A wrapper for Africas Talking Notifications.""" # The default descriptive name associated with the Notification service_name = "Africas Talking" # The services URL service_url = "https://africastalking.com/" # The default secure protocol secure_protocol = "atalk" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/africas_talking/" # Africas Talking API Request URLs notify_url = { AfricasTalkingSMSMode.BULKSMS: ( "https://api.africastalking.com/version1/messaging" ), AfricasTalkingSMSMode.PREMIUM: ( "https://content.africastalking.com/version1/messaging" ), AfricasTalkingSMSMode.SANDBOX: ( "https://api.sandbox.africastalking.com/version1/messaging" ), } # The maximum allowable characters allowed in the title per message title_maxlen = 0 # The maximum allowable characters allowed in the body per message body_maxlen = 160 # The maximum amount of phone numbers that can reside within a single # batch transfer default_batch_size = 50 # Define object templates templates = ("{schema}://{appuser}@{apikey}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "appuser": { "name": _("App User Name"), "type": "string", "regex": (r"^[A-Z0-9_-]+$", "i"), "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9_-]+$", "i"), }, "target_phone": { "name": _("Target Phone"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "apikey": { "alias_of": "apikey", }, "from": { # Your registered short code or alphanumeric "name": _("From"), "type": "string", "default": "AFRICASTKNG", "map_to": "sender", }, "mode": { "name": _("SMS Mode"), "type": "choice:string", "values": AFRICAS_TALKING_SMS_MODES, "default": AFRICAS_TALKING_SMS_MODES[0], }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, appuser, apikey, targets=None, sender=None, batch=None, mode=None, **kwargs, ): """Initialize Africas Talking Object.""" super().__init__(**kwargs) self.appuser = validate_regex( appuser, *self.template_tokens["appuser"]["regex"] ) if not self.appuser: msg = ( f"The Africas Talking appuser specified ({appuser}) is" " invalid." ) self.logger.warning(msg) raise TypeError(msg) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = ( f"The Africas Talking apikey specified ({apikey}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Prepare Sender self.sender = ( self.template_args["from"]["default"] if sender is None else sender ) # Prepare Batch Mode Flag self.batch = ( self.template_args["batch"]["default"] if batch is None else batch ) self.mode = ( self.template_args["mode"]["default"] if not isinstance(mode, str) else mode.lower() ) if isinstance(mode, str) and mode: self.mode = next( ( a for a in AFRICAS_TALKING_SMS_MODES if a.startswith(mode.lower()) ), None, ) if self.mode not in AFRICAS_TALKING_SMS_MODES: msg = ( f"The Africas Talking mode specified ({mode}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) else: self.mode = self.template_args["mode"]["default"] # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number # Carry forward '+' if defined, otherwise do not... self.targets.append( ("+" + result["full"]) if target.lstrip()[0] == "+" else result["full"] ) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Africas Talking Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Africas Talking recipients to notify" ) return False headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", "apiKey": self.apikey, } # error tracking (used for function return) has_error = False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # Create a copy of the target list for index in range(0, len(self.targets), batch_size): # Prepare our payload payload = { "username": self.appuser, "to": ",".join(self.targets[index : index + batch_size]), "from": self.sender, "message": body, } # Acquire our URL notify_url = self.notify_url[self.mode] self.logger.debug( "Africas Talking POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Africas Talking Payload: {payload!s}") # Printable target detail p_target = ( self.targets[index] if batch_size == 1 else f"{len(self.targets[index:index + batch_size])} target(s)" ) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Sample response # { # "SMSMessageData": { # "Message": "Sent to 1/1 Total Cost: KES 0.8000", # "Recipients": [{ # "statusCode": 101, # "number": "+254711XXXYYY", # "status": "Success", # "cost": "KES 0.8000", # "messageId": "ATPid_SampleTxnId123" # }] # } # } if r.status_code not in (100, 101, 102, requests.codes.ok): # We had a problem status_str = ( NotifyAfricasTalking.http_response_code_lookup( r.status_code, AFRICAS_TALKING_HTTP_ERROR_MAP ) ) self.logger.warning( "Failed to send Africas Talking notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info( f"Sent Africas Talking notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Africas Talking " f"notification to {p_target}." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.appuser, self.apikey) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } if self.sender != self.template_args["from"]["default"]: # Set our sender if it was set params["from"] = self.sender if self.mode != self.template_args["mode"]["default"]: # Set our mode params["mode"] = self.mode # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{appuser}@{apikey}/{targets}?{params}".format( schema=self.secure_protocol, appuser=NotifyAfricasTalking.quote(self.appuser, safe=""), apikey=self.pprint(self.apikey, privacy, safe=""), targets="/".join( [NotifyAfricasTalking.quote(x, safe="+") for x in self.targets] ), params=NotifyAfricasTalking.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The Application User ID results["appuser"] = NotifyAfricasTalking.unquote(results["user"]) # Prepare our targets results["targets"] = [] # Our Application APIKey if "apikey" in results["qsd"] and len(results["qsd"]["apikey"]): # Store our apikey if specified as keyword results["apikey"] = NotifyAfricasTalking.unquote( results["qsd"]["apikey"] ) # This means our host is actually a phone number (target) results["targets"].append( NotifyAfricasTalking.unquote(results["host"]) ) else: # First item is our apikey results["apikey"] = NotifyAfricasTalking.unquote(results["host"]) # Store our remaining targets found on path results["targets"].extend( NotifyAfricasTalking.split_path(results["fullpath"]) ) # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["sender"] = NotifyAfricasTalking.unquote( results["qsd"]["from"] ) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyAfricasTalking.parse_phone_no( results["qsd"]["to"] ) # Get our Mode if "mode" in results["qsd"] and len(results["qsd"]["mode"]): results["mode"] = NotifyAfricasTalking.unquote( results["qsd"]["mode"] ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyAfricasTalking.template_args["batch"]["default"] ) ) return results ================================================ FILE: apprise/plugins/apprise_api.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging import re import requests from .. import exception from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase class AppriseAPIMethod: """Defines the method to post data tot he remote server.""" JSON = "json" FORM = "form" APPRISE_API_METHODS = ( AppriseAPIMethod.FORM, AppriseAPIMethod.JSON, ) class NotifyAppriseAPI(NotifyBase): """A wrapper for Apprise (Persistent) API Notifications.""" # The default descriptive name associated with the Notification service_name = "Apprise API" # The services URL service_url = "https://github.com/caronc/apprise-api" # The default protocol protocol = "apprise" # The default secure protocol secure_protocol = "apprises" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/apprise_api/" # Support attachments attachment_support = True # Depending on the number of transactions/notifications taking place, this # could take a while. 30 seconds should be enough to perform the task socket_read_timeout = 30.0 # Disable throttle rate for Apprise API requests since they are normally # local anyway request_rate_per_sec = 0.0 # Define object templates templates = ( "{schema}://{host}/{token}", "{schema}://{host}:{port}/{token}", "{schema}://{user}@{host}/{token}", "{schema}://{user}@{host}:{port}/{token}", "{schema}://{user}:{password}@{host}/{token}", "{schema}://{user}:{password}@{host}:{port}/{token}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "token": { "name": _("Token"), "type": "string", "required": True, "private": True, "regex": (r"^[A-Z0-9_-]{1,128}$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "tags": { "name": _("Tags"), "type": "string", }, "method": { "name": _("Query Method"), "type": "choice:string", "values": APPRISE_API_METHODS, "default": APPRISE_API_METHODS[0], }, "to": { "alias_of": "token", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, } def __init__( self, token=None, tags=None, method=None, headers=None, **kwargs ): """Initialize Apprise API Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Apprise API token specified ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.lower() ) if self.method not in APPRISE_API_METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Build list of tags self.__tags = parse_list(tags) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) return def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) if self.__tags: params["tags"] = ",".join(list(self.__tags)) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyAppriseAPI.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyAppriseAPI.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 fullpath = self.fullpath.strip("/") return ( "{schema}://{auth}{hostname}{port}{fullpath}{token}" "/?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a # valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( "/{}/".format(NotifyAppriseAPI.quote(fullpath, safe="/")) if fullpath else "/" ), token=self.pprint(self.token, privacy, safe=""), params=NotifyAppriseAPI.urlencode(params), ) ) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Apprise API Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) attachments = [] files = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Apprise API attachment" f" {attachment.url(privacy=True)}." ) return False try: # Our Attachment filename filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if self.method == AppriseAPIMethod.JSON: # Output must be in a DataURL format (that's what # PushSafer calls it): attachments.append({ "filename": filename, "base64": attachment.base64(), "mimetype": attachment.mimetype, }) else: # AppriseAPIMethod.FORM files.append(( f"file{no:02d}", ( filename, # file handle is safely closed in `finally`; # inline open is intentional open(attachment.path, "rb"), # noqa: SIM115 attachment.mimetype, ), )) except (TypeError, OSError, exception.AppriseException): # We could not access the attachment self.logger.error( "Could not access AppriseAPI attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending AppriseAPI attachment" f" {attachment.url(privacy=True)}" ) # prepare Apprise API Object payload = { # Apprise API Payload "title": title, "body": body, "type": notify_type.value, "format": self.notify_format.value, } if self.method == AppriseAPIMethod.JSON: headers["Content-Type"] = "application/json" if attachments: payload["attachments"] = attachments payload = dumps(payload) if self.__tags: payload["tag"] = self.__tags auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" fullpath = self.fullpath.strip("/") url += "{}".format("/" + fullpath) if fullpath else "" url += f"/notify/{self.token}" # Some entries can not be over-ridden headers.update({ # Our response to be in JSON format always "Accept": "application/json", # Pass our Source UUID4 Identifier "X-Apprise-ID": self.asset._uid, # Pass our current recursion count to our upstream server "X-Apprise-Recursion-Count": str(self.asset._recursion + 1), }) # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( "Apprise API POST URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Apprise API Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( url, data=payload, headers=headers, auth=auth, files=files if files else None, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyAppriseAPI.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Apprise API notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Return; we're done return False else: self.logger.info( "Sent Apprise API notification; method=%s.", self.method ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Apprise API " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False except OSError as e: self.logger.warning( "An I/O error occurred while reading one of the " "attached files." ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: for file in files: # Ensure all files are closed file[1][1].close() return True @staticmethod def parse_native_url(url): """ Support http://hostname/notify/token and http://hostname/path/notify/token """ result = re.match( r"^http(?Ps?)://(?P[A-Z0-9._-]+)" r"(:(?P[0-9]+))?" r"(?P/[^?]+?)?/notify/(?P[A-Z0-9_-]{1,32})/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyAppriseAPI.parse_url( "{schema}://{hostname}{port}{path}/{token}/{params}".format( schema=( NotifyAppriseAPI.secure_protocol if result.group("secure") else NotifyAppriseAPI.protocol ), hostname=result.group("hostname"), port=( "" if not result.group("port") else ":{}".format(result.group("port")) ), path=( "" if not result.group("path") else result.group("path") ), token=result.group("token"), params=( "" if not result.group("params") else "?{}".format(result.group("params")) ), ) ) return None @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) for x, y in results["qsd+"].items() } # Support the passing of tags in the URL if "tags" in results["qsd"] and len(results["qsd"]["tags"]): results["tags"] = NotifyAppriseAPI.parse_list( results["qsd"]["tags"] ) # Support the 'to' & 'token' variable so that we can support rooms # this way too. if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyAppriseAPI.unquote( results["qsd"]["token"] ) elif "to" in results["qsd"] and len(results["qsd"]["to"]): results["token"] = NotifyAppriseAPI.unquote(results["qsd"]["to"]) else: # Start with a list of path entries to work with entries = NotifyAppriseAPI.split_path(results["fullpath"]) if entries: # use our last entry found results["token"] = entries[-1] # pop our last entry off entries = entries[:-1] # re-assemble our full path results["fullpath"] = "/".join(entries) # Set method if specified if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyAppriseAPI.unquote( results["qsd"]["method"] ) return results ================================================ FILE: apprise/plugins/aprs.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # To use this plugin, you need to be a licensed ham radio operator # # Plugin constraints: # # - message length = 67 chars max. # - message content = ASCII 7 bit # - APRS messages will be sent without msg ID, meaning that # ham radio operators cannot acknowledge them # - Bring your own APRS-IS passcode. If you don't know what # this is or how to get it, then this plugin is not for you # - Do NOT change the Device/ToCall ID setting UNLESS this # module is used outside of Apprise. This identifier helps # the ham radio community with determining the software behind # a given APRS message. # - With great (ham radio) power comes great responsibility; do # not use this plugin for spamming other ham radio operators # # In order to digest text input which is not in plain English, # users can install the optional 'unidecode' package as part # of their venv environment. Details: see plugin description # # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - aprs://{user}:{password}@{callsign} # - aprs://{user}:{password}@{callsign1}/{callsign2} # Optional parameters: # - locale --> APRS-IS target server to connect with # Default: EURO --> 'euro.aprs2.net' # Details: https://www.aprs2.net/ # # APRS message format specification: # http://www.aprs.org/doc/APRS101.PDF # import contextlib from itertools import chain import re import socket import sys from .. import __version__ from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_call_sign, parse_call_sign from .base import NotifyBase # Fixed APRS-IS server locales # Default is 'EURO' # See https://www.aprs2.net/ for details # Select the rotating server in case you # don"t care about a specific locale APRS_LOCALES = { "NOAM": "noam.aprs2.net", "SOAM": "soam.aprs2.net", "EURO": "euro.aprs2.net", "ASIA": "asia.aprs2.net", "AUNZ": "aunz.aprs2.net", "ROTA": "rotate.aprs2.net", } # Identify all unsupported characters APRS_BAD_CHARMAP = { r"Ä": "Ae", r"Ö": "Oe", r"Ü": "Ue", r"ä": "ae", r"ö": "oe", r"ü": "ue", r"ß": "ss", } # Our compiled mapping of bad characters APRS_COMPILED_MAP = re.compile(r"(" + "|".join(APRS_BAD_CHARMAP.keys()) + r")") class NotifyAprs(NotifyBase): """A wrapper for APRS Notifications via APRS-IS.""" # The default descriptive name associated with the Notification service_name = "Aprs" # The services URL service_url = "https://www.aprs2.net/" # The default secure protocol secure_protocol = "aprs" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/aprs/" # APRS default port, supported by all core servers # Details: https://www.aprs-is.net/Connecting.aspx notify_port = 10152 # The maximum length of the APRS message body body_maxlen = 67 # Apprise APRS Device ID / TOCALL ID # This is a FIXED value which is associated with this plugin. # Its value MUST NOT be changed. If you use this APRS plugin # code OUTSIDE of Apprise, please request your own TOCALL ID. # Details: see https://github.com/aprsorg/aprs-deviceid # # Do NOT use the generic "APRS" TOCALL ID !!!!! # device_id = "APPRIS" # A title can not be used for APRS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Helps to reduce the number of login-related errors where the # APRS-IS server "isn't ready yet". If we try to receive the rx buffer # without this grace perid in place, we may receive "incomplete" responses # where the login response lacks information. In case you receive too many # "Rx: APRS-IS msg is too short - needs to have at least two lines" error # messages, you might want to increase this value to a larger time span # Per previous experience, do not use values lower than 0.5 (seconds) request_rate_per_sec = 0.8 # Encoding of retrieved content aprs_encoding = "latin-1" # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_callsign": { "name": _("Target Callsign"), "type": "string", "regex": ( r"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$", "i", ), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "name": _("Target Callsign"), "type": "string", "map_to": "targets", }, "delay": { "name": _("Resend Delay"), "type": "float", "min": 0.0, "max": 5.0, "default": 0.0, }, "locale": { "name": _("Locale"), "type": "choice:string", "values": APRS_LOCALES, "default": "EURO", }, }, ) def __init__(self, targets=None, locale=None, delay=None, **kwargs): """Initialize APRS Object.""" super().__init__(**kwargs) # Our (future) socket sobject self.sock = None # Parse our targets self.targets = [] """ Check if the user has provided credentials """ if not (self.user and self.password): msg = "An APRS user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) """ Check if the user tries to use a read-only access to APRS-IS. We need to send content, meaning that read-only access will not work """ if self.password == "-1": msg = "APRS read-only passwords are not supported." self.logger.warning(msg) raise TypeError(msg) """ Check if the password is numeric """ if not self.password.isnumeric(): msg = "Invalid APRS-IS password" self.logger.warning(msg) raise TypeError(msg) """ Convert given user name (FROM callsign) and device ID to to uppercase """ self.user = self.user.upper() self.device_id = self.device_id.upper() """ Check if the user has provided a locale for the APRS-IS-server and validate it, if necessary """ if locale and locale.upper() not in APRS_LOCALES: msg = ( "Unsupported APRS-IS server locale. " "Received: {}. Valid: {}".format( locale, ", ".join(str(x) for x in APRS_LOCALES) ) ) self.logger.warning(msg) raise TypeError(msg) # Update our delay if delay is None: self.delay = NotifyAprs.template_args["delay"]["default"] else: try: self.delay = float(delay) if ( self.delay < NotifyAprs.template_args["delay"]["min"] or self.delay >= NotifyAprs.template_args["delay"]["max"] ): raise ValueError() except (TypeError, ValueError): msg = f"Unsupported APRS-IS delay ({delay}) specified. " self.logger.warning(msg) raise TypeError(msg) from None # Bump up our request_rate self.request_rate_per_sec += self.delay # Set the transmitter group self.locale = ( NotifyAprs.template_args["locale"]["default"] if not locale else locale.upper() ) # Used for URL generation afterwards only self.invalid_targets = [] for target in parse_call_sign(targets): # Validate targets and drop bad ones # We just need to know if the call sign (including SSID, if # provided) is valid and can then process the input as is result = is_call_sign(target) if not result: self.logger.warning( f"Dropping invalid Amateur radio call sign ({target}).", ) self.invalid_targets.append(target.upper()) continue # Store entry self.targets.append(target.upper()) return def socket_close(self): """Closes the socket connection whereas present.""" if self.sock: with contextlib.suppress(Exception): self.sock.close() self.sock = None def socket_open(self): """Establishes the connection to the APRS-IS socket server.""" self.logger.debug( "Creating socket connection with APRS-IS" f" {APRS_LOCALES[self.locale]}:{self.notify_port}" ) try: self.sock = socket.create_connection( (APRS_LOCALES[self.locale], self.notify_port), self.socket_connect_timeout, ) except ConnectionError as e: self.logger.debug("Socket Exception socket_open: %s", e) self.sock = None return False except socket.gaierror as e: self.logger.debug("Socket Exception socket_open: %s", e) self.sock = None return False except socket.timeout as e: self.logger.debug( "Socket Timeout Exception socket_open: %s", e) self.sock = None return False except Exception as e: self.logger.debug("General Exception socket_open: %s", e) self.sock = None return False # We are connected. # getpeername() is not supported by every OS. Therefore, # we MAY receive an exception even though we are # connected successfully. try: # Get the physical host/port of the server host, port = self.sock.getpeername() # and create debug info self.logger.debug(f"Connected to {host}:{port}") except ValueError: # Seens as if we are running on an operating # system that does not support getpeername() # Create a minimal log file entry self.logger.debug("Connected to APRS-IS") # Return success return True def aprsis_login(self): """Generate the APRS-IS login string, send it to the server and parse the response. Returns True/False wrt whether the login was successful """ self.logger.debug("socket_login: init") # Check if we are connected if not self.sock: self.logger.warning("socket_login: Not connected to APRS-IS") return False # APRS-IS login string, see https://www.aprs-is.net/Connecting.aspx login_str = ( f"user {self.user} pass {self.password} vers apprise" f" {__version__}\r\n" ) # Send the data & abort in case of error if not self.socket_send(login_str): self.logger.warning( "socket_login: Login to APRS-IS unsuccessful," " exception occurred" ) self.socket_close() return False rx_buf = self.socket_receive(len(login_str) + 100) # Abort the remaining process in case an error has occurred if not rx_buf: self.logger.warning( "socket_login: Login to APRS-IS " "unsuccessful, exception occurred" ) self.socket_close() return False # APRS-IS sends at least two lines of data # The data that we need is in line #2 so # let's split the content and see what we have rx_lines = rx_buf.splitlines() if len(rx_lines) < 2: self.logger.warning( "socket_login: APRS-IS msg is too short" " - needs to have at least two lines" ) self.socket_close() return False # Now split the 2nd line's content and extract # both call sign and login status try: _, _, callsign, status, _ = rx_lines[1].split(" ", 4) except ValueError: # ValueError is returned if there were not enough elements to # populate the response self.logger.warning( "socket_login: received invalid response from APRS-IS" ) self.socket_close() return False if callsign != self.user: self.logger.warning(f"socket_login: call signs differ: {callsign}") self.socket_close() return False if status.startswith("unverified"): self.logger.warning( "socket_login: invalid APRS-IS password for given call sign" ) self.socket_close() return False # all validations are successful; we are connected return True def socket_send(self, tx_data): """Generic "Send data to a socket".""" self.logger.debug("socket_send: init") # Check if we are connected if not self.sock: self.logger.warning("socket_send: Not connected to APRS-IS") return False # Encode our data if we are on Python3 or later payload = ( tx_data.encode("utf-8") if sys.version_info[0] >= 3 else tx_data ) # Always call throttle before any remote server i/o is made self.throttle() # Try to open the socket # Send the content to APRS-IS try: self.sock.setblocking(True) self.sock.settimeout(self.socket_connect_timeout) self.sock.sendall(payload) except socket.gaierror as e: self.logger.warning(f"Socket Exception socket_send: {e!s}") self.sock = None return False except socket.timeout as e: self.logger.warning(f"Socket Timeout Exception socket_send: {e!s}") self.sock = None return False except Exception as e: self.logger.warning(f"General Exception socket_send: {e!s}") self.sock = None return False self.logger.debug("socket_send: successful") # mandatory on several APRS-IS servers # helps to reduce the number of errors where # the server only returns an abbreviated message return True def socket_reset(self): """Resets the socket's buffer.""" self.logger.debug("socket_reset: init") _ = self.socket_receive(0) self.logger.debug("socket_reset: successful") return True def socket_receive(self, rx_len): """Generic "Receive data from a socket".""" self.logger.debug("socket_receive: init") # Check if we are connected if not self.sock: self.logger.warning("socket_receive: not connected to APRS-IS") return False # len is zero in case we intend to # reset the socket if rx_len > 0: self.logger.debug("socket_receive: Receiving data from APRS-IS") # Receive content from the socket try: self.sock.setblocking(False) self.sock.settimeout(self.socket_connect_timeout) rx_buf = self.sock.recv(rx_len) except socket.gaierror as e: self.logger.warning(f"Socket Exception socket_receive: {e!s}") self.sock = None return False except socket.timeout as e: self.logger.warning( f"Socket Timeout Exception socket_receive: {e!s}" ) self.sock = None return False except Exception as e: self.logger.warning(f"General Exception socket_receive: {e!s}") self.sock = None return False rx_buf = ( rx_buf.decode(self.aprs_encoding) if sys.version_info[0] >= 3 else rx_buf ) # There will be no data in case we reset the socket if rx_len > 0: self.logger.debug(f"Received content: {rx_buf}") self.logger.debug("socket_receive: successful") return rx_buf.rstrip() def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform APRS Notification.""" if not self.targets: # There is no one to notify; we're done self.logger.warning( "There are no amateur radio call signs to notify" ) return False # prepare payload payload = body # sock object is "None" if we were unable to establish a connection # In case of errors, the error message has already been sent # to the logger object if not self.socket_open(): return False # We have established a successful connection # to the socket server. Now send the login information if not self.aprsis_login(): return False # Login & authorization confirmed # reset what is in our buffer self.socket_reset() # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) self.logger.debug("Starting Payload setup") # Prepare the outgoing message # Due to APRS's contraints, we need to do # a lot of filtering before we can send # the actual message # # First remove all characters from the # payload that would break APRS # see https://www.aprs.org/doc/APRS101.PDF pg. 71 payload = re.sub(r"[{}|~]+", "", payload) payload = APRS_COMPILED_MAP.sub( # pragma: no branch lambda x: APRS_BAD_CHARMAP[x.group()], payload ) # Finally, constrain output string to 67 characters as # APRS messages are limited in length payload = payload[:67] # Our outgoing message MUST end with a CRLF so # let's amend our payload respectively payload = payload.rstrip("\r\n") + "\r\n" self.logger.debug(f"Payload setup complete: {payload}") # send the message to our target call sign(s) for index in range(0, len(targets)): # prepare the output string # Format: # Device ID/TOCALL - our call sign - target call sign - body buffer = ( f"{self.user}>{self.device_id}::{targets[index]:9}:{payload}" ) # and send the content to the socket # Note that there will be no response from APRS and # that all exceptions are handled within the 'send' method self.logger.debug(f"Sending APRS message: {buffer}") # send the content if not self.socket_send(buffer): has_error = True break # Finally, reset our socket buffer # we DO NOT read from the socket as we # would simply listen to the default APRS-IS stream self.socket_reset() self.logger.debug("Closing socket.") self.socket_close() self.logger.info( "Sent %d/%d APRS-IS notification(s)", index + 1, len(targets) ) return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = {} if self.locale != NotifyAprs.template_args["locale"]["default"]: # Store our locale if not default params["locale"] = self.locale if self.delay != NotifyAprs.template_args["delay"]["default"]: # Store our locale if not default params["delay"] = f"{self.delay:.2f}" # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyAprs.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( chain( [self.pprint(x, privacy, safe="") for x in self.targets], [ self.pprint(x, privacy, safe="") for x in self.invalid_targets ], ) ), params=NotifyAprs.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.user, self.password, self.locale) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if targets > 0 else 1 def __del__(self): """Ensure we close any lingering connections.""" self.socket_close() @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyAprs.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend(NotifyAprs.split_path(results["fullpath"])) # Get Delay (if set) if "delay" in results["qsd"] and len(results["qsd"]["delay"]): results["delay"] = NotifyAprs.unquote(results["qsd"]["delay"]) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyAprs.parse_list(results["qsd"]["to"]) # Set our APRS-IS server locale's key value and convert it to uppercase if "locale" in results["qsd"] and len(results["qsd"]["locale"]): results["locale"] = NotifyAprs.unquote( results["qsd"]["locale"] ).upper() return results ================================================ FILE: apprise/plugins/bark.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # API: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python # import json import requests from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool, parse_list from .base import NotifyBase # Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds BARK_SOUNDS = ( "alarm.caf", "anticipate.caf", "bell.caf", "birdsong.caf", "bloom.caf", "calypso.caf", "chime.caf", "choo.caf", "descent.caf", "electronic.caf", "fanfare.caf", "glass.caf", "gotosleep.caf", "healthnotification.caf", "horn.caf", "ladder.caf", "mailsent.caf", "minuet.caf", "multiwayinvitation.caf", "newmail.caf", "newsflash.caf", "noir.caf", "paymentsuccess.caf", "shake.caf", "sherwoodforest.caf", "silence.caf", "spell.caf", "suspense.caf", "telegraph.caf", "tiptoes.caf", "typewriters.caf", "update.caf", ) # Supported Level Entries class NotifyBarkLevel: """Defines the Bark Level options.""" ACTIVE = "active" TIME_SENSITIVE = "timeSensitive" PASSIVE = "passive" CRITICAL = "critical" BARK_LEVELS = ( NotifyBarkLevel.ACTIVE, NotifyBarkLevel.TIME_SENSITIVE, NotifyBarkLevel.PASSIVE, NotifyBarkLevel.CRITICAL, ) class NotifyBark(NotifyBase): """A wrapper for Notify Bark Notifications.""" # The default descriptive name associated with the Notification service_name = "Bark" # The services URL service_url = "https://github.com/Finb/Bark" # The default protocol protocol = "bark" # The default secure protocol secure_protocol = "barks" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bark/" # Allows the user to specify the NotifyImageSize object; this is supported # through the webhook image_size = NotifyImageSize.XY_128 # Define object templates templates = ( "{schema}://{host}/{targets}", "{schema}://{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "target_device": { "name": _("Target Device"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "sound": { "name": _("Sound"), "type": "choice:string", "values": BARK_SOUNDS, }, "level": { "name": _("Level"), "type": "choice:string", "values": BARK_LEVELS, }, "volume": { "name": _("Volume"), "type": "int", "min": 0, "max": 10, }, "click": { "name": _("Click"), "type": "string", }, "badge": { "name": _("Badge"), "type": "int", "min": 0, }, "category": { "name": _("Category"), "type": "string", }, "group": { "name": _("Group"), "type": "string", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, "icon": { "name": _("Icon URL"), "type": "string", }, "call": { "name": _("Call"), "type": "bool", "default": False, }, "to": { "alias_of": "targets", }, }, ) def __init__( self, targets=None, include_image=True, sound=None, category=None, group=None, level=None, click=None, badge=None, volume=None, icon=None, call=None, **kwargs, ): """Initialize Notify Bark Object.""" super().__init__(**kwargs) # Prepare our URL self.notify_url = "{}://{}{}/push".format( "https" if self.secure else "http", self.host, ( f":{self.port}" if (self.port and isinstance(self.port, int)) else "" ), ) # Assign our category self.category = category if isinstance(category, str) else None # Assign our group self.group = group if isinstance(group, str) else None # Initialize device list self.targets = parse_list(targets) # Place an image inline with the message body self.include_image = include_image # A clickthrough option for notifications self.click = click # Badge try: # Acquire our badge count if we can: # - We accept both the integer form as well as a string # representation self.badge = int(badge) if self.badge < 0: raise ValueError() except TypeError: # NoneType means use Default; this is an okay exception self.badge = None except ValueError: self.badge = None self.logger.warning( "The specified Bark badge ({}) is not valid ", badge ) # Sound (easy-lookup) self.sound = ( None if not sound else next( (f for f in BARK_SOUNDS if f.startswith(sound.lower())), None ) ) if sound and not self.sound: self.logger.warning( "The specified Bark sound ({}) was not found ", sound ) # Volume self.volume = None if volume is not None: try: self.volume = int(volume) if volume is not None else None if self.volume is not None and not (0 <= self.volume <= 10): raise ValueError() except (TypeError, ValueError): self.logger.warning( "The specified Bark volume ({}) is not valid. " "Must be between 0 and 10", volume, ) # Call self.call = parse_bool(call) # Icon URL self.icon = icon if isinstance(icon, str) else None # Level self.level = ( None if not level else next((f for f in BARK_LEVELS if f[0] == level[0]), None) ) if level and not self.level: self.logger.warning( "The specified Bark level ({}) is not valid ", level ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Bark Notification.""" # error tracking (used for function return) has_error = False if not self.targets: # We have nothing to notify; we're done self.logger.warning("There are no Bark devices to notify") return False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # Prepare our payload (sample below) # { # "body": "Test Bark Server", # "markdown": "# Markdown Content", # "device_key": "nysrshcqielvoxsa", # "title": "bleem", # "category": "category", # "sound": "minuet.caf", # "badge": 1, # "icon": "https://day.app/assets/images/avatar.jpg", # "group": "test", # "level": "active", # "volume": 5, # "call": 1, # "url": "https://mritd.com" # } payload = { "title": title if title else self.app_desc, } if self.notify_format == NotifyFormat.MARKDOWN: payload["markdown"] = body else: payload["body"] = body # Acquire our image url if configured to do so image_url = ( None if not self.include_image else self.image_url(notify_type) ) # Use custom icon if provided, otherwise use default image if self.icon: payload["icon"] = self.icon elif image_url: payload["icon"] = image_url if self.sound: payload["sound"] = self.sound if self.click: payload["url"] = self.click if self.badge: payload["badge"] = self.badge if self.level: payload["level"] = self.level if self.category: payload["category"] = self.category if self.group: payload["group"] = self.group if self.volume: payload["volume"] = self.volume if self.call: payload["call"] = 1 auth = None if self.user: auth = (self.user, self.password) # Create a copy of the targets targets = list(self.targets) while len(targets) > 0: # Retrieve our device key target = targets.pop() payload["device_key"] = target self.logger.debug( "Bark POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Bark Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBark.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Bark notification to {}: " "{}{}error={}.".format( target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info(f"Sent Bark notification to {target}.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Bark " f"notification to {target}." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": "yes" if self.include_image else "no", } if self.sound: params["sound"] = self.sound if self.click: params["click"] = self.click if self.badge: params["badge"] = str(self.badge) if self.level: params["level"] = self.level if self.volume: params["volume"] = str(self.volume) if self.category: params["category"] = self.category if self.group: params["group"] = self.group if self.icon: params["icon"] = self.icon if self.call: params["call"] = "yes" # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyBark.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyBark.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), targets="/".join([NotifyBark.quote(f"{x}") for x in self.targets]), params=NotifyBark.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return len(self.targets) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Apply our targets results["targets"] = NotifyBark.split_path(results["fullpath"]) # Category if "category" in results["qsd"] and results["qsd"]["category"]: results["category"] = NotifyBark.unquote( results["qsd"]["category"].strip() ) # Group if "group" in results["qsd"] and results["qsd"]["group"]: results["group"] = NotifyBark.unquote( results["qsd"]["group"].strip() ) # Badge if "badge" in results["qsd"] and results["qsd"]["badge"]: results["badge"] = NotifyBark.unquote( results["qsd"]["badge"].strip() ) # Volume if "volume" in results["qsd"] and results["qsd"]["volume"]: results["volume"] = NotifyBark.unquote( results["qsd"]["volume"].strip() ) # Level if "level" in results["qsd"] and results["qsd"]["level"]: results["level"] = NotifyBark.unquote( results["qsd"]["level"].strip() ) # Click (URL) if "click" in results["qsd"] and results["qsd"]["click"]: results["click"] = NotifyBark.unquote( results["qsd"]["click"].strip() ) # Sound if "sound" in results["qsd"] and results["qsd"]["sound"]: results["sound"] = NotifyBark.unquote( results["qsd"]["sound"].strip() ) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBark.parse_list(results["qsd"]["to"]) # use image= for consistency with the other plugins results["include_image"] = parse_bool( results["qsd"].get("image", True) ) # Icon URL if "icon" in results["qsd"] and results["qsd"]["icon"]: results["icon"] = NotifyBark.unquote( results["qsd"]["icon"].strip() ) # Call results["call"] = parse_bool( results["qsd"].get("call", False) ) return results ================================================ FILE: apprise/plugins/base.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import asyncio from collections.abc import Generator from datetime import tzinfo from functools import partial import re from typing import Any, ClassVar, Optional, TypedDict, Union from zoneinfo import ZoneInfo from ..apprise_attachment import AppriseAttachment from ..common import ( NOTIFY_FORMATS, OVERFLOW_MODES, NotifyFormat, NotifyImageSize, NotifyType, OverflowMode, PersistentStoreMode, ) from ..locale import Translatable, gettext_lazy as _ from ..persistent_store import PersistentStore from ..url import URLBase from ..utils.format import smart_split from ..utils.parse import parse_bool from ..utils.time import zoneinfo class RequirementsSpec(TypedDict, total=False): """Defines our plugin requirements.""" packages_required: Optional[Union[str, list[str]]] packages_recommended: Optional[Union[str, list[str]]] details: Optional[Translatable] class NotifyBase(URLBase): """This is the base class for all notification services.""" # An internal flag used to test the state of the plugin. If set to # False, then the plugin is not used. Plugins can disable themselves # due to enviroment issues (such as missing libraries, or platform # dependencies that are not present). By default all plugins are # enabled. enabled = True # The category allows for parent inheritance of this object to alter # this when it's function/use is intended to behave differently. The # following category types exist: # # native: Is a native plugin written/stored in `apprise/plugins/Notify*` # custom: Is a custom plugin written/stored in a users plugin directory # that they loaded at execution time. category = "native" # Some plugins may require additional packages above what is provided # already by Apprise. # # Use this section to relay this information to the users of the script to # help guide them with what they need to know if they plan on using your # plugin. The below configuration should otherwise accommodate all normal # situations and will not requrie any updating: requirements: ClassVar[RequirementsSpec] = { # Use the description to provide a human interpretable description of # what is required to make the plugin work. This is only nessisary # if there are package dependencies. Setting this to default will # cause a general response to be returned. Only set this if you plan # on over-riding the default. Always consider language support here. # So before providing a value do the following in your code base: # # from apprise.AppriseLocale import gettext_lazy as _ # # 'details': _('My detailed requirements') "details": None, # Define any required packages needed for the plugin to run. This is # an array of strings that simply look like lines residing in a # `requirements.txt` file... # # As an example, an entry may look like: # 'packages_required': [ # 'cryptography < 3.4`, # ] "packages_required": [], # Recommended packages identify packages that are not required to make # your plugin work, but would improve it's use or grant it access to # full functionality (that might otherwise be limited). # Similar to `packages_required`, you would identify each entry in # the array as you would in a `requirements.txt` file. # # - Do not re-provide entries already in the `packages_required` "packages_recommended": [], } # The services URL service_url = None # A URL that takes you to the setup/help of the specific protocol setup_url = None # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives # us a safe play range. Override the one defined already in the URLBase request_rate_per_sec = 5.5 # Allows the user to specify the NotifyImageSize object image_size = None # The maximum allowable characters allowed in the body per message body_maxlen = 32768 # Defines the maximum allowable characters in the title; set this to zero # if a title can't be used. Titles that are not used but are defined are # automatically placed into the body title_maxlen = 250 # Set the maximum line count; if this is set to anything larger then zero # the message (prior to it being sent) will be truncated to this number # of lines. Setting this to zero disables this feature. body_max_line_count = 0 # Persistent storage default settings persistent_storage = True # Timezone Default; by setting it to None, the timezone detected # on the server is used timezone = None # Default Notify Format notify_format = NotifyFormat.TEXT # Default Overflow Mode overflow_mode = OverflowMode.UPSTREAM # Our default is to no not use persistent storage beyond in-memory # reference storage_mode = PersistentStoreMode.MEMORY # Default Emoji Interpretation interpret_emojis = False # Support Attachments; this defaults to being disabled. # Since apprise allows you to send attachments without a body or title # defined, by letting Apprise know the plugin won't support attachments # up front, it can quickly pass over and ignore calls to these end points. # You must set this to true if your application can handle attachments. # You must also consider a flow change to your notification if this is set # to True as well as now there will be cases where both the body and title # may not be set. There will never be a case where a body, or attachment # isn't set in the same call to your notify() function. attachment_support = False # Default Title HTML Tagging # When a title is specified for a notification service that doesn't accept # titles, by default apprise tries to give a plesant view and convert the # title so that it can be placed into the body. The default is to just # use a tag. The below causes the title to get generated: default_html_tag_id = "b" # Here is where we define all of the arguments we accept on the url # such as: schema://whatever/?overflow=upstream&format=text # These act the same way as tokens except they are optional and/or # have default values set if mandatory. This rule must be followed template_args = dict( URLBase.template_args, **{ "overflow": { "name": _("Overflow Mode"), "type": "choice:string", "values": OVERFLOW_MODES, # Provide a default "default": overflow_mode, # look up default using the following parent class value at # runtime. The variable name identified here (in this case # overflow_mode) is checked and it's result is placed over-top # of the 'default'. This is done because once a parent class # inherits this one, the overflow_mode already set as a default # 'could' be potentially over-ridden and changed to a different # value. "_lookup_default": "overflow_mode", }, "format": { "name": _("Notify Format"), "type": "choice:string", "values": NOTIFY_FORMATS, # Provide a default "default": notify_format, # look up default using the following parent class value at # runtime. "_lookup_default": "notify_format", }, "emojis": { "name": _("Interpret Emojis"), # SSL Certificate Authority Verification "type": "bool", # Provide a default "default": interpret_emojis, # look up default using the following parent class value at # runtime. "_lookup_default": "interpret_emojis", }, "store": { "name": _("Persistent Storage"), # Use Persistent Storage "type": "bool", # Provide a default "default": persistent_storage, # look up default using the following parent class value at # runtime. "_lookup_default": "persistent_storage", }, "tz": { "name": _("Timezone"), "type": "string", # Provide a default "default": timezone, # look up default using the following parent class value at # runtime. "_lookup_default": "timezone", }, }, ) # # Overflow Defaults / Configuration applicable to SPLIT mode only # # Display Count [X/X] # ^^^^^^ # \\\\\\ # 6 characters (space + count) # Display Count [XX/XX] # ^^^^^^^^ # \\\\\\\\ # 8 characters (space + count) # Display Count [XXX/XXX] # ^^^^^^^^^^ # \\\\\\\\\\ # 10 characters (space + count) # Display Count [XXXX/XXXX] # ^^^^^^^^^^^^ # \\\\\\\\\\\\ # 12 characters (space + count) # # Given the above + some buffer we come up with the following: # If this value is exceeded, display counts automatically shut off overflow_max_display_count_width = 12 # The number of characters to reserver for whitespace buffering # This is detected automatically, but you can enforce a value if # you desire: overflow_buffer = 0 # the min accepted length of a title to allow for a counter display overflow_display_count_threshold = 130 # Whether or not when over-flow occurs, if the title should be repeated # each time the message is split up # - None: Detect # - True: Always display title once # - False: Display the title for each occurance overflow_display_title_once = None # If this is set to to True: # The title_maxlen should be considered as a subset of the body_maxlen # Hence: len(title) + len(body) should never be greater then body_maxlen # # If set to False, then there is no corrorlation between title_maxlen # restrictions and that of body_maxlen overflow_amalgamate_title = False # Identifies the timezone to use; if this is not over-ridden, then the # timezone defined in the AppriseAsset() object is used instead. The # Below is expected to be in a ZoneInfo type already. You can have this # automatically initialized by specifying ?tz= on the Apprise URLs __tzinfo = None def __init__(self, **kwargs): """Initialize some general configuration that will keep things consistent when working with the notifiers that will inherit this class.""" super().__init__(**kwargs) # Store our interpret_emoji's setting # If asset emoji value is set to a default of True and the user # specifies it to be false, this is accepted and False over-rides. # # If asset emoji value is set to a default of None, a user may # optionally over-ride this and set it to True from the Apprise # URL. ?emojis=yes # # If asset emoji value is set to a default of False, then all emoji's # are turned off (no user over-rides allowed) # # Our Persistent Storage object is initialized on demand self.__store = None # Take a default self.interpret_emojis = self.asset.interpret_emojis if "emojis" in kwargs: # possibly over-ride default self.interpret_emojis = bool( self.interpret_emojis in (None, True) and parse_bool( kwargs.get("emojis", False), default=NotifyBase.template_args["emojis"]["default"], ) ) if "format" in kwargs: value = kwargs["format"] try: self.notify_format = ( value if isinstance(value, NotifyFormat) else NotifyFormat(value.lower()) ) except (AttributeError, ValueError): err = ( f"An invalid notification format ({value}) was " "specified.") self.logger.warning(err) raise TypeError(err) from None if "tz" in kwargs: value = kwargs["tz"] self.__tzinfo = zoneinfo(value) if not self.__tzinfo: err = ( f"An invalid notification timezone ({value}) was " "specified.") self.logger.warning(err) raise TypeError(err) from None if "overflow" in kwargs: value = kwargs["overflow"] try: self.overflow_mode = ( value if isinstance(value, OverflowMode) else OverflowMode(value.lower()) ) except (AttributeError, ValueError): err = f"An invalid overflow method ({value}) was specified." self.logger.warning(err) raise TypeError(err) from None # Prepare our Persistent Storage switch self.persistent_storage = parse_bool( kwargs.get("store", NotifyBase.persistent_storage) ) if not self.persistent_storage: # Enforce the disabling of cache (ortherwise defaults are use) self.url_identifier = False self.__cached_url_identifier = None def image_url( self, notify_type: NotifyType, image_size: Optional[NotifyImageSize] = None, logo: bool = False, extension: Optional[str] = None, ) -> Optional[str]: """Returns Image URL if possible.""" image_size = self.image_size if image_size is None else image_size if not image_size: return None return self.asset.image_url( notify_type=notify_type, image_size=image_size, logo=logo, extension=extension, ) def image_path( self, notify_type: NotifyType, extension: Optional[str] = None, ) -> Optional[str]: """Returns the path of the image if it can.""" if not self.image_size: return None return self.asset.image_path( notify_type=notify_type, image_size=self.image_size, extension=extension, ) def image_raw( self, notify_type: NotifyType, extension: Optional[str] = None, ) -> Optional[bytes]: """Returns the raw image if it can.""" if not self.image_size: return None return self.asset.image_raw( notify_type=notify_type, image_size=self.image_size, extension=extension, ) def color( self, notify_type: NotifyType, color_type: Optional[type] = None, ) -> Union[str, int, tuple[int, int, int]]: """Returns the html color (hex code) associated with the notify_type.""" return self.asset.color( notify_type=notify_type, color_type=color_type, ) def ascii( self, notify_type: NotifyType, ) -> str: """Returns the ascii characters associated with the notify_type.""" return self.asset.ascii( notify_type=notify_type, ) def notify(self, *args: Any, **kwargs: Any) -> bool: """Performs notification.""" try: # Build a list of dictionaries that can be used to call send(). send_calls = list(self._build_send_calls(*args, **kwargs)) except TypeError: # Internal error return False else: # Loop through each call, one at a time. (Use a list rather than a # generator to call all the partials, even in case of a failure.) the_calls = [self.send(**kwargs2) for kwargs2 in send_calls] return all(the_calls) async def async_notify(self, *args: Any, **kwargs: Any) -> bool: """Performs notification for asynchronous callers.""" try: # Build a list of dictionaries that can be used to call send(). send_calls = list(self._build_send_calls(*args, **kwargs)) except TypeError: # Internal error return False else: loop = asyncio.get_event_loop() # Wrap each call in a coroutine that uses the default executor. # TODO: In the future, allow plugins to supply a native # async_send() method. async def do_send(**kwargs2): send = partial(self.send, **kwargs2) result = await loop.run_in_executor(None, send) return result # gather() all calls in parallel. the_cors = (do_send(**kwargs2) for kwargs2 in send_calls) return all(await asyncio.gather(*the_cors)) def _build_send_calls( self, body: Optional[str] = None, title: Optional[str] = None, notify_type: NotifyType = NotifyType.INFO, overflow: Optional[Union[str, OverflowMode]] = None, attach: Optional[Union[list[str], AppriseAttachment]] = None, body_format: Optional[NotifyFormat] = None, **kwargs: Any, ) -> Generator[dict[str, Any], None, None]: """Get a list of dictionaries that can be used to call send() or (in the future) async_send().""" if not self.enabled: # Deny notifications issued to services that are disabled msg = f"{self.service_name} is currently disabled on this system." self.logger.warning(msg) raise TypeError(msg) # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): try: attach = AppriseAttachment(attach, asset=self.asset) except TypeError: # bad attachments raise # Handle situations where the body is None body = body if body else "" elif not (body or attach): # If there is not an attachment at the very least, a body must be # present msg = "No message body or attachment was specified." self.logger.warning(msg) raise TypeError(msg) if not body and not self.attachment_support: # If no body was specified, then we know that an attachment # was. This is logic checked earlier in the code. # # Knowing this, if the plugin itself doesn't support sending # attachments, there is nothing further to do here, just move # along. msg = ( f"{self.service_name} does not support attachments; " " service skipped" ) self.logger.warning(msg) raise TypeError(msg) # Handle situations where the title is None title = title if title else "" # Truncate flag set with attachments ensures that only 1 # attachment passes through. In the event there could be many # services specified, we only want to do this logic once. # The logic is only applicable if ther was more then 1 attachment # specified overflow = self.overflow_mode if overflow is None else overflow if attach and len(attach) > 1 and overflow == OverflowMode.TRUNCATE: # Save first attachment attach_ = AppriseAttachment(attach[0], asset=self.asset) else: # reference same attachment attach_ = attach # Apply our overflow (if defined) for chunk in self._apply_overflow( body=body, title=title, overflow=overflow, body_format=body_format ): # Send notification yield { "body": chunk["body"], "title": chunk["title"], "notify_type": notify_type, "attach": attach_, "body_format": body_format, } def _apply_overflow( self, body: Optional[str], title: Optional[str] = None, overflow: Optional[Union[str, OverflowMode]] = None, body_format: Optional[NotifyFormat] = None, ) -> list[dict[str, str]]: """ Apply overflow behaviour (UPSTREAM, TRUNCATE, SPLIT) to title/body. Takes the message body and title as input. This function then applies any defined overflow restrictions associated with the notification service and may alter the message if/as required. The function will always return a list object in the following structure: [ { title: 'the title goes here', body: 'the message body goes here', }, { title: 'the title goes here', body: 'the continued message body goes here', }, ] """ response: list[dict[str, str]] = [] # Tidy title = "" if not title else title.strip() body = "" if not body else body.rstrip() # Default overflow mode if overflow is None: overflow = self.overflow_mode # Default effective body format if body_format is None: body_format = self.notify_format # If the service does not support a title, amalgamate into body if self.title_maxlen <= 0 and len(title) > 0: if self.notify_format == NotifyFormat.HTML: body = ( f"<{self.default_html_tag_id}>{title}" f"" f"
\r\n{body}" ) elif ( self.notify_format == NotifyFormat.MARKDOWN and body_format == NotifyFormat.TEXT ): # Content is appended to body as markdown title = title.lstrip("\r\n \t\v\f#-") if title: body = f"# {title}\r\n{body}" else: body = f"{title}\r\n{body}" title = "" # Enforce line count if self.body_max_line_count > 0: lines = re.split(r"\r*\n", body) body = "\r\n".join(lines[0 : self.body_max_line_count]) # UPSTREAM mode: do not touch content if overflow == OverflowMode.UPSTREAM: response.append({"body": body, "title": title}) return response # a value of '2' allows for the \r\n that is applied when amalgamating overflow_buffer = ( max(2, self.overflow_buffer) if (self.title_maxlen == 0 and len(title)) else self.overflow_buffer ) # # TRUNCATE and SPLIT require sizing logic # # Handle situations where body and title are amalgamated title_maxlen = ( self.title_maxlen if not self.overflow_amalgamate_title else min( len(title) + self.overflow_max_display_count_width, self.title_maxlen, self.body_maxlen, ) ) if len(title) > title_maxlen: # Truncate our title title = title[:title_maxlen].rstrip() # Compute body_maxlen as per legacy logic if ( self.overflow_amalgamate_title and (self.body_maxlen - overflow_buffer) >= title_maxlen ): # status quo body_maxlen = ( self.body_maxlen if not title else (self.body_maxlen - title_maxlen) ) - overflow_buffer else: # If the body fits, we're done body_maxlen = ( self.body_maxlen if not self.overflow_amalgamate_title else (self.body_maxlen - overflow_buffer) ) # If the body fits, we are done if body_maxlen > 0 and len(body) <= body_maxlen: response.append({"body": body, "title": title}) return response # TRUNCATE mode: hard truncation (no smart-splitting) if overflow == OverflowMode.TRUNCATE: response.append({ "body": body[:body_maxlen].lstrip("\r\n\x0b\x0c").rstrip(), "title": title, }) return response # # SPLIT mode # # Detect if we only display our title once or not (legacy logic) if self.overflow_display_title_once is None: # Detect if we only display our title once or not: overflow_display_title_once = bool( self.overflow_amalgamate_title and body_maxlen < self.overflow_display_count_threshold ) else: overflow_display_title_once = self.overflow_display_title_once # SPLIT mode with repeated title (with/without counter) if not overflow_display_title_once and not ( # edge case: amalgamated title but no body space self.overflow_amalgamate_title and body_maxlen <= 0 ): # Decide whether to show a counter (legacy condition) show_counter = ( title and len(body) > body_maxlen and ( ( self.overflow_amalgamate_title and body_maxlen >= self.overflow_display_count_threshold ) or ( not self.overflow_amalgamate_title and title_maxlen > self.overflow_display_count_threshold ) ) and ( title_maxlen > (self.overflow_max_display_count_width + overflow_buffer) and self.title_maxlen >= self.overflow_display_count_threshold ) ) effective_body_maxlen = body_maxlen if show_counter: # introduce padding for the counter effective_body_maxlen -= overflow_buffer # Use smart splitting instead of naive slicing chunks = smart_split( body, effective_body_maxlen, body_format, ) count = len(chunks) template = "" if show_counter: digits = len(str(count)) overflow_display_count_width = 4 + (digits * 2) if ( overflow_display_count_width <= self.overflow_max_display_count_width ): # Truncate title further if needed to make room for counter if ( len(title) > title_maxlen - overflow_display_count_width ): title = title[ : title_maxlen - overflow_display_count_width ] template = f" [{{:0{digits}d}}/{{:0{digits}d}}]" else: # Too many messages; fall back to repeated title without # counter displayed show_counter = False response = [] for idx, chunk_body in enumerate(chunks, start=1): suffix = template.format(idx, count) if show_counter else "" response.append({ "body": chunk_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": f"{title}{suffix}", }) else: # # SPLIT mode, display title once and move on # (this covers both overflow_display_title_once=True # and the edge case body_maxlen <= 0 with amalgamated title) # response = [] consumed = 0 remainder = body if body_maxlen > 0 and body: # First chunk uses body_maxlen (which already accounts for # the title) first_chunks = smart_split( body, body_maxlen, body_format, ) first_body = first_chunks[0] if first_chunks else "" consumed = len(first_body) remainder = body[consumed:] response.append({ "body": first_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": title, }) else: # body_maxlen <= 0 or no body; send title only, still honouring # body response.append({ "body": "", "title": title, }) # remainder stays as full body; will be split below # Remaining chunks: no title, use the full body_maxlen of the # service if remainder: more_chunks = smart_split( remainder, self.body_maxlen, body_format, ) for chunk_body in more_chunks: response.append({ "body": chunk_body.lstrip("\r\n\x0b\x0c").rstrip(), "title": "", }) return response def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, **kwargs: Any, ) -> bool: """Should preform the actual notification itself.""" raise NotImplementedError( "send() is not implimented by the child class." ) def url_parameters( self, *args: Any, **kwargs: Any, ) -> dict[str, Any]: """Provides a default set of parameters to work with. This can greatly simplify URL construction in the acommpanied url() function in all defined plugin services. """ params = { "format": self.notify_format.value, "overflow": self.overflow_mode.value, } # Timezone Information (if ZoneInfo) if self.__tzinfo and isinstance(self.__tzinfo, ZoneInfo): params["tz"] = self.__tzinfo.key # Persistent Storage Setting if self.persistent_storage != NotifyBase.persistent_storage: params["store"] = "yes" if self.persistent_storage else "no" params.update(super().url_parameters(*args, **kwargs)) # return default parameters return params @staticmethod def parse_url( url: str, verify_host: bool = True, plus_to_space: bool = False, ) -> Optional[dict[str, Any]]: """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed URL which some child classes will later use to verify SSL keys (if SSL transactions take place). Unless under very specific circumstances, it is strongly recomended that you leave this default value set to True. Returns: A dictionary is returned containing the URL fully parsed if successful, otherwise None is returned. """ results = URLBase.parse_url( url, verify_host=verify_host, plus_to_space=plus_to_space ) if not results: # We're done; we failed to parse our url return results # Allow overriding the default format if "format" in results["qsd"]: results["format"] = results["qsd"].get("format", "").lower() if results["format"] not in NOTIFY_FORMATS: URLBase.logger.warning( "Unsupported format specified " f"{results['qsd']['format']!r}" ) del results["format"] # Allow overriding the default overflow if "overflow" in results["qsd"]: results["overflow"] = results["qsd"].get("overflow", "").lower() if results["overflow"] not in OVERFLOW_MODES: URLBase.logger.warning( "Unsupported overflow mode specified " f"{results['qsd']['overflow']!r}" ) del results["overflow"] # Allow emoji's override if "emojis" in results["qsd"]: results["emojis"] = parse_bool(results["qsd"].get("emojis")) # Store our persistent storage boolean # Allow overriding the default timezone if "tz" in results["qsd"]: results["tz"] = results["qsd"].get("tz", "") if "store" in results["qsd"]: results["store"] = results["qsd"]["store"] return results @staticmethod def parse_native_url(url: str) -> Optional[dict[str, Any]]: """This is a base class that can be optionally over-ridden by child classes who can build their Apprise URL based on the one provided by the notification service they choose to use. The intent of this is to make Apprise a little more userfriendly to people who aren't familiar with constructing URLs and wish to use the ones that were just provied by their notification serivice that they're using. This function will return None if the passed in URL can't be matched as belonging to the notification service. Otherwise this function should return the same set of results that parse_url() does. """ return None @property def store(self): """Returns a pointer to our persistent store for use. The best use cases are: self.store.get('key') self.store.set('key', 'value') self.store.delete('key1', 'key2', ...) You can also access the keys this way: self.store['key'] And clear them: del self.store['key'] """ if self.__store is None: # Initialize our persistent store for use self.__store = PersistentStore( namespace=self.url_id(), path=self.asset.storage_path, mode=self.asset.storage_mode, ) return self.__store @property def tzinfo(self) -> tzinfo: """Returns our tzinfo file associated with this plugin if set otherwise the default timezone is returned. """ return self.__tzinfo if self.__tzinfo else self.asset.tzinfo ================================================ FILE: apprise/plugins/bluesky.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # 1. Create a BlueSky account # 2. Access Settings -> Privacy and Security # 3. Generate an App Password. Optionally grant yourself access to Direct # Messages if you want to be able to send them # 4. Assemble your Apprise URL like: # bluesky://handle@you-token-here # from datetime import datetime, timedelta, timezone import json import re import requests from ..attachment.base import AttachBase from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from .base import NotifyBase # For parsing handles HANDLE_HOST_PARSE_RE = re.compile(r"(?P[^.]+)\.+(?P.+)$") IS_USER = re.compile(r"^\s*@?(?P[A-Z0-9_]+)(\.+(?P.+))?$", re.I) class NotifyBlueSky(NotifyBase): """A wrapper for BlueSky Notifications.""" # The default descriptive name associated with the Notification service_name = "BlueSky" # The services URL service_url = "https://bluesky.us/" # Protocol secure_protocol = ("bsky", "bluesky") # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bluesky/" # Support attachments attachment_support = True # XRPC Suffix URLs; Structured as: # https://host/{suffix} # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) # 1 hour in seconds (the lifetime of our token) access_token_lifetime_sec = timedelta(seconds=3600) # Detect your Decentralized Identitifer (DID), then you can get your Auth # Token. xrpc_suffix_did = "/xrpc/com.atproto.identity.resolveHandle" xrpc_suffix_session = "/xrpc/com.atproto.server.createSession" xrpc_suffix_record = "/xrpc/com.atproto.repo.createRecord" xrpc_suffix_blob = "/xrpc/com.atproto.repo.uploadBlob" plc_directory = "https://plc.directory/{did}" # BlueSky is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # RateLimit-Reset: The epoc time (in seconds) we can expect our # rate-limit to be reset. # RateLimit-Remaining: an integer identifying how many requests we're # still allow to make. request_rate_per_sec = 0 # For Tracking Purposes ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Remaining messages ratelimit_remaining = 1 # The default BlueSky host to use if one isn't specified bluesky_default_host = "bsky.social" # Our message body size body_maxlen = 280 # BlueSky does not support a title title_maxlen = 0 # Define object templates templates = ("{schema}://{user}@{password}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("Username"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, }, ) def __init__(self, **kwargs): """Initialize BlueSky Object.""" super().__init__(**kwargs) # Our access token self.__access_token = self.store.get("access_token") self.__refresh_token = None self.__access_token_expiry = datetime.now(timezone.utc) self.__endpoint = self.store.get("endpoint") if not self.user: msg = "A BlueSky UserID/Handle must be specified." self.logger.warning(msg) raise TypeError(msg) # Set our default host self.host = self.bluesky_default_host self.__endpoint = ( f"https://{self.host}" if not self.host else self.__endpoint ) # Identify our Handle (if define) results = HANDLE_HOST_PARSE_RE.match(self.user) if results: self.user = results.group("handle").strip() self.host = results.group("host").strip() return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform BlueSky Notification.""" if not self.__access_token and not self.login(): # We failed to authenticate - we're done return False # Track our returning blob IDs as they're stored on the BlueSky server blobs = [] if attach and self.attachment_support: url = f"{self.__endpoint}{self.xrpc_suffix_blob}" # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access attachment" f" {attachment.url(privacy=True)}." ) return False if not re.match(r"^image/.*", attachment.mimetype, re.I): # Only support images at this time self.logger.warning( "Ignoring unsupported BlueSky attachment" f" {attachment.url(privacy=True)}." ) continue self.logger.debug( "Preparing BlueSky attachment" f" {attachment.url(privacy=True)}" ) # Upload our image and get our blob associated with it postokay, response = self._fetch( url, payload=attachment, ) if not postokay: # We can't post our attachment return False # Prepare our filename filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) if not (isinstance(response, dict) and response.get("blob")): self.logger.debug( "Could not attach the file to BlueSky: %s (mime=%s)", filename, attachment.mimetype, ) continue blobs.append((response.get("blob"), filename)) # Prepare our URL did, endpoint = self.get_identifier() url = f"{endpoint}{self.xrpc_suffix_record}" # prepare our batch of payloads to create payloads = [] payload = { "collection": "app.bsky.feed.post", "repo": did, "record": { "text": body, # 'YYYY-mm-ddTHH:MM:SSZ' "createdAt": datetime.now(tz=timezone.utc).strftime("%FT%XZ"), "$type": "app.bsky.feed.post", }, } if blobs: for no, blob in enumerate(blobs, start=1): payload_ = payload.copy() if no > 1: # # multiple instances # # 1. update createdAt time # 2. Change text to identify image no payload_["record"]["createdAt"] = datetime.now( tz=timezone.utc ).strftime("%FT%XZ") payload_["record"]["text"] = f"{no:02d}/{len(blobs):02d}" payload_["record"]["embed"] = { "images": [{ "image": blob[0], "alt": blob[1], }], "$type": "app.bsky.embed.images", } payloads.append(payload_) else: payloads.append(payload) for payload in payloads: # Send Login Information postokay, response = self._fetch( url, payload=json.dumps(payload), ) if not postokay: # We failed # Bad responses look like: # { # 'error': 'InvalidRequest', # 'message': 'reason' # } return False return True def get_identifier(self, user=None, login=False): """Performs a Decentralized User Lookup and returns the identifier.""" if user is None: user = self.user user = f"{user}.{self.host}" if "." not in user else f"{user}" did_key = f"did.{user}" endpoint_key = f"endpoint.{user}" did = self.store.get(did_key) endpoint = self.store.get(endpoint_key) if did and endpoint: # Early return return did, endpoint # Step 1: Acquire DID from bsky.app url = f"https://public.api.bsky.app{self.xrpc_suffix_did}" params = {"handle": user} # Send Login Information postokay, response = self._fetch( url, params=params, method="GET", # We set this boolean so internal recursion doesn't take place. login=login, ) if not postokay or not response or "did" not in response: # We failed return (False, False) # Store our DID did = response.get("did") # Step 2: Use DID to find the PDS if did.startswith("did:plc:"): pds_url = self.plc_directory.format(did=did) # PDS Query postokay, service_response = self._fetch( pds_url, method="GET", # We set this boolean so internal recursion doesn't take place. login=login, ) if ( not postokay or not service_response or "service" not in service_response ): # We failed return (False, False) endpoint = next( ( s["serviceEndpoint"] for s in service_response.get("service", []) if s["type"] == "AtprotoPersonalDataServer" ), None, ) elif did.startswith("did:web:"): # Convert to domain domain = did[8:] web_did_url = f"https://{domain}/.well-known/did.json" postokay, service_response = self._fetch( web_did_url, method="GET", # We set this boolean so internal recursion doesn't take place. login=login, ) if ( not postokay or not service_response or "service" not in service_response ): # We failed self.logger.warning( "Could not fetch DID document for did:web identity " f"{did}; ensure {web_did_url} is available." ) return (False, False) endpoint = next( ( s["serviceEndpoint"] for s in service_response.get("service", []) if s["type"] == "AtprotoPersonalDataServer" ), None, ) else: self.logger.warning( f"Unknown BlueSky DID scheme detected in {did}" ) return (False, False) # Step 3: Send to correct endpoint if not endpoint: self.logger.warning("Failed to resolve BlueSky PDS endpoint") return (False, False) self.store.set(did_key, did) self.store.set(endpoint_key, endpoint) return (did, endpoint) def login(self): """A simple wrapper to authenticate with the BlueSky Server.""" # Acquire our Decentralized Identitifer did, self.__endpoint = self.get_identifier(self.user, login=True) if not did: return False url = f"{self.__endpoint}{self.xrpc_suffix_session}" payload = { "identifier": did, "password": self.password, } # Send Login Information postokay, response = self._fetch( url, payload=json.dumps(payload), # We set this boolean so internal recursion doesn't take place. login=True, ) # Our response object looks like this (content has been altered for # presentation purposes): # { # 'did': 'did:plc:ruk414jakghak402j1jqekj2', # 'didDoc': { # '@context': [ # 'https://www.w3.org/ns/did/v1', # 'https://w3id.org/security/multikey/v1', # 'https://w3id.org/security/suites/secp256k1-2019/v1' # ], # 'id': 'did:plc:ruk414jakghak402j1jqekj2', # 'alsoKnownAs': ['at://apprise.bsky.social'], # 'verificationMethod': [ # { # 'id': 'did:plc:ruk414jakghak402j1jqekj2#atproto', # 'type': 'Multikey', # 'controller': 'did:plc:ruk414jakghak402j1jqekj2', # 'publicKeyMultibase' 'redacted' # } # ], # 'service': [ # { # 'id': '#atproto_pds', # 'type': 'AtprotoPersonalDataServer', # 'serviceEndpoint': # 'https://woodtuft.us-west.host.bsky.network' # } # ] # }, # 'handle': 'apprise.bsky.social', # 'email': 'whoami@gmail.com', # 'emailConfirmed': True, # 'emailAuthFactor': False, # 'accessJwt': 'redacted', # 'refreshJwt': 'redacted', # 'active': True, # } if not postokay or not response: # We failed return False # Acquire our Token self.__access_token = response.get("accessJwt") # Handle other optional arguments we can use self.__access_token_expiry = ( self.access_token_lifetime_sec + datetime.now(timezone.utc) - self.clock_skew ) # The Refresh Token self.__refresh_token = response.get("refreshJwt", self.__refresh_token) self.store.set( "access_token", self.__access_token, self.__access_token_expiry ) self.store.set( "refresh_token", self.__refresh_token, self.__access_token_expiry ) self.store.set("endpoint", self.__endpoint) self.logger.info( f"Authenticated to BlueSky as {self.user}.{self.host}" ) return True def _fetch( self, url, payload=None, params=None, method="POST", content_type=None, login=False, ): """Wrapper to BlueSky API requests object.""" # use what was specified, otherwise build headers dynamically headers = { "User-Agent": self.app_id, "Content-Type": ( payload.mimetype if isinstance(payload, AttachBase) else ( "application/x-www-form-urlencoded; charset=utf-8" if method == "GET" else "application/json" ) ), } if self.__access_token: # Set our token headers["Authorization"] = f"Bearer {self.__access_token}" # Some Debug Logging self.logger.debug( f"BlueSky {method} URL:" f" {url} (cert_verify={self.verify_certificate})" ) self.logger.debug( "BlueSky Payload: %s", ( str(payload) if not isinstance(payload, AttachBase) else "attach: " + payload.name ), ) # By default set wait to None wait = None if self.ratelimit_remaining == 0: # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the # Twitter server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds # We add 0.3 seconds to the end just to allow a grace # period. wait = (self.ratelimit_reset - now).total_seconds() + 0.3 # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # Initialize a default value for our content value content = {} # acquire our request mode fn = requests.post if method == "POST" else requests.get try: r = fn( url, data=( payload if not isinstance(payload, AttachBase) else payload.open() ), params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) # Get our JSON content if it's possible try: content = json.loads(r.content) except (TypeError, ValueError, AttributeError): # TypeError = r.content is not a String # ValueError = r.content is Unparsable # AttributeError = r.content is None content = {} # Rate limit handling... our header objects at this point are: # 'RateLimit-Limit': '10', # Total # of requests per hour # 'RateLimit-Remaining': '9', # Requests remaining # 'RateLimit-Reset': '1741631362', # Epoch Time # 'RateLimit-Policy': '10;w=86400' # NoEntries;w= try: # Capture rate limiting if possible self.ratelimit_remaining = int( r.headers.get("ratelimit-remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("ratelimit-reset")), timezone.utc ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information # gracefully accept this state and move on pass if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBlueSky.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send BlueSky {} to {}: {}error={}.".format( method, url, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure return (False, content) except requests.RequestException as e: self.logger.warning( f"Exception received when sending BlueSky {method} to {url}: " ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure return (False, content) except OSError as e: self.logger.warning( "An I/O error occurred while handling {}.".format( payload.name if isinstance(payload, AttachBase) else payload ) ) self.logger.debug(f"I/O Exception: {e!s}") return (False, content) return (True, content) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol[0], self.user, self.password, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Apply our other parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) user = self.user if self.host != self.bluesky_default_host: user += f".{self.host}" # our URL return "{schema}://{user}@{password}?{params}".format( schema=self.secure_protocol[0], user=NotifyBlueSky.quote(user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), params=NotifyBlueSky.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results if not results.get("password") and results["host"]: results["password"] = NotifyBlueSky.unquote(results["host"]) # Do not use host field results["host"] = None return results ================================================ FILE: apprise/plugins/brevo.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # API Reference: https://developers.brevo.com/reference/getting-started-1 from json import dumps import logging from os.path import splitext import requests from .. import exception from ..common import NotifyFormat, NotifyType from ..conversion import convert_between from ..locale import gettext_lazy as _ from ..utils.parse import is_email, parse_list, validate_regex from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Extend HTTP Error Messages (most common Brevo SMTP errors) BREVO_HTTP_ERROR_MAP = { 400: "Bad Request - Invalid payload or missing parameters.", 401: "Unauthorized - Invalid Brevo API key.", 402: "Payment Required - Plan limitation or credit issue.", 429: "Too Many Requests - Rate limit exceeded.", } # Comprehensive list of Brevo-supported extensions for Transactional Emails # Source: Brevo API Documentation & Transactional Attachment Guidelines BREVO_VALID_EXTENSIONS = ( # Documents & Text "xlsx", "xls", "ods", "docx", "docm", "doc", "csv", "pdf", "txt", "rtf", "msg", "pub", "mobi", "ppt", "pptx", "eps", "odt", "ics", "xml", "css", "html", "htm", "shtml", # Images "gif", "jpg", "jpeg", "png", "tif", "tiff", "bmp", "cgm", # Archives "zip", "tar", "ez", "pkpass", # Audio "mp3", "m4a", "m4v", "wma", "ogg", "flac", "wav", "aif", "aifc", "aiff", # Video "mp4", "mov", "avi", "mkv", "mpeg", "mpg", "wmv" ) class NotifyBrevo(NotifyBase): """A wrapper for Notify Brevo Notifications.""" # The default descriptive name associated with the Notification service_name = "Brevo" # The services URL service_url = "https://www.brevo.com/" # The default secure protocol secure_protocol = "brevo" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/brevo/" # Default to markdown notify_format = NotifyFormat.HTML # The default Email API URL to use notify_url = "https://api.brevo.com/v3/smtp/email" # Support attachments attachment_support = True # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.2 # The default subject to use if one isn't specified. default_empty_subject = "" # Define object templates templates = ( "{schema}://{apikey}:{from_email}", "{schema}://{apikey}:{from_email}/{targets}", ) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "regex": (r"^[a-zA-Z0-9._-]+$", "i"), }, "from_email": { "name": _("Source Email"), "type": "string", "required": True, }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "reply": { "name": _("Reply To Email"), "type": "string", "map_to": "reply_to", }, "to": { "alias_of": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) def __init__( self, apikey, from_email, targets=None, reply_to=None, cc=None, bcc=None, **kwargs, ): """Initialize Notify Brevo Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Brevo API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) result = is_email(from_email) if not result: msg = f"Invalid ~From~ email specified: {from_email}" self.logger.warning(msg) raise TypeError(msg) # Store email address self.from_email = result["full_email"] # Reply-to self.reply_to = None if reply_to: result = is_email(reply_to) if not result: msg = "An invalid Brevo Reply To ({}) was specified.".format( f"{reply_to}") self.logger.warning(msg) raise TypeError(msg) self.reply_to = ( result["name"] if result["name"] else False, result["full_email"], ) # Acquire Targets (To Emails) self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Validate recipients (to:) and drop bad ones: if targets: for recipient in parse_list(targets): result = is_email(recipient) if result: self.targets.append(result["full_email"]) continue self.logger.warning( f"Dropped invalid email ({recipient}) specified.", ) else: # add ourselves self.targets.append(self.from_email) # Validate recipients (cc:) and drop bad ones: for recipient in parse_list(cc): result = is_email(recipient) if result: self.cc.add(result["full_email"]) continue self.logger.warning( f"Dropped invalid Carbon Copy email ({recipient}) specified.", ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_list(bcc): result = is_email(recipient) if result: self.bcc.add(result["full_email"]) continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " f"({recipient}) specified.", ) return @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.apikey, self.from_email) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) if len(self.cc) > 0: # Handle our Carbon Copy Addresses params["cc"] = ",".join(self.cc) if len(self.bcc) > 0: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join(self.bcc) if self.reply_to: # Handle our reply to address params["reply"] = ( "{} <{}>".format(*self.reply_to) if self.reply_to[0] else self.reply_to[1] ) # a simple boolean check as to whether we display our target emails # or not has_targets = not ( len(self.targets) == 1 and self.targets[0] == self.from_email ) return "{schema}://{apikey}:{from_email}/{targets}?{params}".format( schema=self.secure_protocol, apikey=self.pprint(self.apikey, privacy, safe=""), # never encode email since it plays a huge role in our hostname from_email=self.from_email, targets=( "" if not has_targets else "/".join( [NotifyBrevo.quote(x, safe="") for x in self.targets] ) ), params=NotifyBrevo.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return max(len(self.targets), 1) def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Brevo Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Brevo email recipients to notify") return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", "api-key": self.apikey, } # error tracking (used for function return) has_error = False # A Simple Email Payload Template payload_ = { "sender": { "email": self.from_email, }, # Placeholder, filled per target "to": [{"email": None}], "subject": title if title else self.default_empty_subject, } # Body selection use_html = self.notify_format == NotifyFormat.HTML if use_html: # body already normalised; keep your existing logic payload_["htmlContent"] = body payload_["textContent"] = convert_between( NotifyFormat.HTML, NotifyFormat.TEXT, body ) else: # Plain text requested, but Brevo still wants HTML payload_["textContent"] = body payload_["htmlContent"] = convert_between( NotifyFormat.TEXT, NotifyFormat.HTML, body ) if attach and self.attachment_support: attachments = [] # Send our attachments for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Brevo attachment" f" {attachment.url(privacy=True)}." ) return False # Brevo does not track content/mime type and relies 100% # entirely on the filename extension as to whether or not it # will accept it or not. # # The below prepares a safe_name (which can't be .dat like # other plugins since Brevo rejects that type). For this # reason .txt is chosen intentionally for this circumstance. # Use the attachment name if available, otherwise default to a # generic name raw_name = attachment.name \ if attachment.name else f"file{no:03}.txt" # If the filename does NOT match a supported extension, append # .txt _, ext = splitext(raw_name) safe_name = f"{raw_name}.txt" if ( not ext or ext[1:].lower() not in BREVO_VALID_EXTENSIONS) else raw_name try: attachments.append({ "content": attachment.base64(), "name": safe_name, }) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Brevo attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Brevo attachment" f" {attachment.url(privacy=True)}" ) # Append our attachments to the payload payload_.update({ "attachment": attachments, }) if self.reply_to: payload_["replyTo"] = {"email": self.reply_to[1]} targets = list(self.targets) while len(targets) > 0: target = targets.pop(0) # Create a copy of our template payload = payload_.copy() # the cc, bcc, to field must be unique or SendMail will fail, the # below code prepares this by ensuring the target isn't in the cc # list or bcc list. It also makes sure the cc list does not contain # any of the bcc entries cc = self.cc - self.bcc - {target} bcc = self.bcc - {target} # Set our main recipient payload["to"] = [{"email": target}] if len(cc): payload["cc"] = [{"email": email} for email in cc] if len(bcc): payload["bcc"] = [{"email": email} for email in bcc] # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io # intensive. # To accommodate this, we only show our debug payload # information if required. self.logger.debug( "Brevo POST URL:" f" {self.notify_url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "Brevo Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.accepted, requests.codes.created, ): # We had a problem status_str = NotifyBrevo.http_response_code_lookup( r.status_code, BREVO_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send Brevo notification to {}: " "{}{}error={}.".format( target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info( f"Sent Brevo notification to {target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Brevo " f"notification to {target}." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Our URL looks like this: # {schema}://{apikey}:{from_email}/{targets} # # which actually equates to: # {schema}://{user}:{password}@{host}/{email1}/{email2}/etc.. # ^ ^ ^ # | | | # apikey -from addr- if not results.get("user"): # An API Key as not properly specified return None if not results.get("password"): # A From Email was not correctly specified return None # Prepare our API Key results["apikey"] = NotifyBrevo.unquote(results["user"]) # Prepare our From Email Address results["from_email"] = "{}@{}".format( NotifyBrevo.unquote(results["password"]), NotifyBrevo.unquote(results["host"]), ) # Acquire our targets results["targets"] = NotifyBrevo.split_path(results["fullpath"]) # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBrevo.parse_list( results["qsd"]["to"] ) # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = NotifyBrevo.parse_list(results["qsd"]["cc"]) # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = NotifyBrevo.parse_list(results["qsd"]["bcc"]) # Handle Reply To Address if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = NotifyBrevo.unquote(results["qsd"]["reply"]) return results ================================================ FILE: apprise/plugins/bulksms.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a BulkSMS account # You will need credits (new accounts start with a few) # https://www.bulksms.com/account/ # # API is documented here: # - https://www.bulksms.com/developer/json/v1/#tag/Message from itertools import chain import json import re import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase IS_GROUP_RE = re.compile( r"^(@?(?P[A-Z0-9_-]+))$", re.IGNORECASE, ) class BulkSMSRoutingGroup: """The different categories of routing.""" ECONOMY = "ECONOMY" STANDARD = "STANDARD" PREMIUM = "PREMIUM" # Used for verification purposes BULKSMS_ROUTING_GROUPS = ( BulkSMSRoutingGroup.ECONOMY, BulkSMSRoutingGroup.STANDARD, BulkSMSRoutingGroup.PREMIUM, ) class BulkSMSEncoding: """The different categories of routing.""" TEXT = "TEXT" UNICODE = "UNICODE" BINARY = "BINARY" class NotifyBulkSMS(NotifyBase): """A wrapper for BulkSMS Notifications.""" # The default descriptive name associated with the Notification service_name = "BulkSMS" # The services URL service_url = "https://bulksms.com/" # All notification requests are secure secure_protocol = "bulksms" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bulksms/" # BulkSMS uses the http protocol with JSON requests notify_url = "https://api.bulksms.com/v1/messages" # The maximum length of the body body_maxlen = 160 # The maximum amount of texts that can go out in one batch default_batch_size = 4000 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "target_group": { "name": _("Target Group"), "type": "string", "prefix": "@", "regex": (r"^[A-Z0-9 _-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "route": { "name": _("Route Group"), "type": "choice:string", "values": BULKSMS_ROUTING_GROUPS, "default": BulkSMSRoutingGroup.STANDARD, }, "unicode": { # Unicode characters "name": _("Unicode Characters"), "type": "bool", "default": True, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, source=None, targets=None, unicode=None, batch=None, route=None, **kwargs, ): """Initialize BulkSMS Object.""" super().__init__(**kwargs) self.source = None if source: result = is_phone_no(source) if not result: msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Tidy source self.source = "+{}".format(result["full"]) # Setup our route self.route = ( self.template_args["route"]["default"] if not isinstance(route, str) else route.upper() ) if self.route not in BULKSMS_ROUTING_GROUPS: msg = f"The route specified ({route}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Define whether or not we should set the unicode flag self.unicode = ( self.template_args["unicode"]["default"] if unicode is None else bool(unicode) ) # Define whether or not we should operate in a batch mode self.batch = ( self.template_args["batch"]["default"] if batch is None else bool(batch) ) # Parse our targets self.targets = [] self.groups = [] for target in parse_phone_no(targets): # Parse each phone number we found result = is_phone_no(target) if result: self.targets.append("+{}".format(result["full"])) continue group_re = IS_GROUP_RE.match(target) if group_re and not target.isdigit(): # If the target specified is all digits, it MUST have a @ # in front of it to eliminate any ambiguity self.groups.append(group_re.group("group")) continue self.logger.warning( f"Dropped invalid phone # and/or Group ({target}) specified.", ) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform BulkSMS Notification.""" if not (self.password and self.user): self.logger.warning( "There were no valid login credentials provided" ) return False if not (self.targets or self.groups): # We have nothing to notify self.logger.warning("There are no BulkSMS targets to notify") return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Prepare our payload payload = { # The To gets populated in the loop below "to": None, "body": body, "routingGroup": self.route, "encoding": ( BulkSMSEncoding.UNICODE if self.unicode else BulkSMSEncoding.TEXT ), # Options are NONE, ALL and ERRORS "deliveryReports": "ERRORS", } if self.source: payload.update({ "from": self.source, }) # Authentication auth = (self.user, self.password) # Prepare our targets targets = ( list(self.targets) if batch_size == 1 else [ self.targets[index : index + batch_size] for index in range(0, len(self.targets), batch_size) ] ) targets += [{"type": "GROUP", "name": g} for g in self.groups] while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["to"] = target # Printable reference if isinstance(target, dict): p_target = target["name"] elif isinstance(target, list): p_target = f"{len(target)} targets" else: p_target = target # Some Debug Logging self.logger.debug( "BulkSMS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"BulkSMS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) # The responsne might look like: # [ # { # "id": "string", # "type": "SENT", # "from": "string", # "to": "string", # "body": null, # "encoding": "TEXT", # "protocolId": 0, # "messageClass": 0, # "numberOfParts": 0, # "creditCost": 0, # "submission": {...}, # "status": {...}, # "relatedSentMessageId": "string", # "userSuppliedId": "string" # } # ] if r.status_code not in ( requests.codes.created, requests.codes.ok, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) # set up our status code to use status_code = r.status_code self.logger.warning( "Failed to send BulkSMS notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info( f"Sent BulkSMS notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending BulkSMS: to %s ", p_target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "unicode": "yes" if self.unicode else "no", "batch": "yes" if self.batch else "no", "route": self.route, } if self.source: params["from"] = self.source # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{user}:{password}@{targets}/?{params}".format( schema=self.secure_protocol, user=self.pprint(self.user, privacy, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( chain( [ NotifyBulkSMS.quote(f"{x}", safe="+") for x in self.targets ], [ NotifyBulkSMS.quote(f"@{x}", safe="@") for x in self.groups ], ) ), params=NotifyBulkSMS.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol, self.user if self.user else None, self.password if self.password else None, ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # # Note: Groups always require a separate request (and can not be # included in batch calculations) batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets + len(self.groups) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = [ NotifyBulkSMS.unquote(results["host"]), *NotifyBulkSMS.split_path(results["fullpath"]), ] # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBulkSMS.unquote(results["qsd"]["from"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBulkSMS.parse_phone_no( results["qsd"]["to"] ) # Unicode Characters results["unicode"] = parse_bool( results["qsd"].get( "unicode", NotifyBulkSMS.template_args["unicode"]["default"] ) ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyBulkSMS.template_args["batch"]["default"] ) ) # Allow one to define a route group if "route" in results["qsd"] and len(results["qsd"]["route"]): results["route"] = NotifyBulkSMS.unquote(results["qsd"]["route"]) return results ================================================ FILE: apprise/plugins/bulkvs.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a BulkVS account # You will need credits (new accounts start with a few) # https://www.bulkvs.com/ # API is documented here: # - https://portal.bulkvs.com/api/v1.0/documentation#/\ # Messaging/post_messageSend import json import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase class NotifyBulkVS(NotifyBase): """A wrapper for BulkVS Notifications.""" # The default descriptive name associated with the Notification service_name = "BulkVS" # The services URL service_url = "https://www.bulkvs.com/" # All notification requests are secure secure_protocol = "bulkvs" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/bulkvs/" # BulkVS uses the http protocol with JSON requests notify_url = "https://portal.bulkvs.com/api/v1.0/messageSend" # The maximum length of the body body_maxlen = 160 # The maximum amount of texts that can go out in one batch default_batch_size = 4000 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ( "{schema}://{user}:{password}@{from_phone}/{targets}", "{schema}://{user}:{password}@{from_phone}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "from_phone": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", "required": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Phone No"), "type": "string", "regex": (r"^\+?[0-9\s)(+-]+$", "i"), "map_to": "source", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__(self, source=None, targets=None, batch=None, **kwargs): """Initialize BulkVS Object.""" super().__init__(**kwargs) if not (self.user and self.password): msg = "A BulkVS user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) result = is_phone_no(source) if not result: msg = ( f"The Account (From) Phone # specified ({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Tidy source self.source = result["full"] # Define whether or not we should operate in a batch mode self.batch = ( self.template_args["batch"]["default"] if batch is None else bool(batch) ) # Parse our targets self.targets = [] has_error = False for target in parse_phone_no(targets): # Parse each phone number we found result = is_phone_no(target) if result: self.targets.append(result["full"]) continue has_error = True self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) if not targets and not has_error: # Default the SMS Message to ourselves self.targets.append(self.source) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform BulkVS Notification.""" if not self.targets: # We have nothing to notify self.logger.warning("There are no BulkVS targets to notify") return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", } # Prepare our payload payload = { # The To gets populated in the loop below "From": self.source, "To": None, "Message": body, } # Authentication auth = (self.user, self.password) # Prepare our targets targets = ( list(self.targets) if batch_size == 1 else [ self.targets[index : index + batch_size] for index in range(0, len(self.targets), batch_size) ] ) while len(targets): # Get our target to notify target = targets.pop(0) # Prepare our user payload["To"] = target # Printable reference if isinstance(target, list): p_target = f"{len(target)} targets" else: p_target = target # Some Debug Logging self.logger.debug( "BulkVS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"BulkVS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=json.dumps(payload), headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) # A Response may look like: # { # "RefId": "5a66dee6-ff7a-40ee-8218-5805c074dc01", # "From": "13109060901", # "MessageType": "SMS|MMS", # "Results": [ # { # "To": "13105551212", # "Status": "SUCCESS" # }, # { # "To": "13105551213", # "Status": "SUCCESS" # } # ] # } if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) # set up our status code to use status_code = r.status_code self.logger.warning( "Failed to send BulkVS notification to {}: " "{}{}error={}.".format( p_target, status_str, ", " if status_str else "", status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info( f"Sent BulkVS notification to {p_target}." ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending BulkVS: to %s ", p_target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.source, self.user, self.password) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # A nice way of cleaning up the URL length a bit targets = ( [] if len(self.targets) == 1 and self.targets[0] == self.source else self.targets ) return ( "{schema}://{user}:{password}@{source}/{targets}?{params}".format( schema=self.secure_protocol, source=self.source, user=self.pprint(self.user, privacy, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), targets="/".join( [NotifyBulkVS.quote(f"{x}", safe="+") for x in targets] ), params=NotifyBulkVS.urlencode(params), ) ) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if self.targets else 1 if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBulkVS.unquote(results["qsd"]["from"]) # hostname will also be a target in this case results["targets"] = [ *NotifyBulkVS.parse_phone_no(results["host"]), *NotifyBulkVS.split_path(results["fullpath"]), ] else: # store our source results["source"] = NotifyBulkVS.unquote(results["host"]) # store targets results["targets"] = NotifyBulkVS.split_path(results["fullpath"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBulkVS.parse_phone_no( results["qsd"]["to"] ) # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyBulkVS.template_args["batch"]["default"] ) ) return results ================================================ FILE: apprise/plugins/burstsms.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Sign-up with https://burstsms.com/ # # Define your API Secret here and acquire your API Key # - https://can.transmitsms.com/profile # import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase class BurstSMSCountryCode: # Australia AU = "au" # New Zeland NZ = "nz" # United Kingdom UK = "gb" # United States US = "us" BURST_SMS_COUNTRY_CODES = ( BurstSMSCountryCode.AU, BurstSMSCountryCode.NZ, BurstSMSCountryCode.UK, BurstSMSCountryCode.US, ) class NotifyBurstSMS(NotifyBase): """A wrapper for Burst SMS Notifications.""" # The default descriptive name associated with the Notification service_name = "Burst SMS" # The services URL service_url = "https://burstsms.com/" # The default protocol secure_protocol = "burstsms" # The maximum amount of SMS Messages that can reside within a single # batch transfer based on: # https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c default_batch_size = 500 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/burstsms/" # Burst SMS uses the http protocol with JSON requests notify_url = "https://api.transmitsms.com/send-sms.json" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{apikey}:{secret}@{sender_id}/{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "required": True, "regex": (r"^[a-z0-9]+$", "i"), "private": True, }, "secret": { "name": _("API Secret"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "sender_id": { "name": _("Sender ID"), "type": "string", "required": True, "map_to": "source", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "from": { "alias_of": "sender_id", }, "key": { "alias_of": "apikey", }, "secret": { "alias_of": "secret", }, "country": { "name": _("Country"), "type": "choice:string", "values": BURST_SMS_COUNTRY_CODES, "default": BurstSMSCountryCode.US, }, # Validity # Expire a message send if it is undeliverable (defined in minutes) # If set to Zero (0); this is the default and sets the max validity # period "validity": {"name": _("validity"), "type": "int", "default": 0}, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, apikey, secret, source, targets=None, country=None, validity=None, batch=None, **kwargs, ): """Initialize Burst SMS Object.""" super().__init__(**kwargs) # API Key (associated with project) self.apikey = validate_regex( apikey, *self.template_tokens["apikey"]["regex"] ) if not self.apikey: msg = f"An invalid Burst SMS API Key ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) # API Secret (associated with project) self.secret = validate_regex( secret, *self.template_tokens["secret"]["regex"] ) if not self.secret: msg = f"An invalid Burst SMS API Secret ({secret}) was specified." self.logger.warning(msg) raise TypeError(msg) if not country: self.country = self.template_args["country"]["default"] else: self.country = country.lower().strip() if country not in BURST_SMS_COUNTRY_CODES: msg = ( f"An invalid Burst SMS country ({country}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Set our Validity self.validity = self.template_args["validity"]["default"] if validity: try: self.validity = int(validity) except (ValueError, TypeError): msg = ( f"The Burst SMS Validity specified ({validity}) is" " invalid." ) self.logger.warning(msg) raise TypeError(msg) from None # Prepare Batch Mode Flag self.batch = ( self.template_args["batch"]["default"] if batch is None else batch ) # The Sender ID self.source = validate_regex(source) if not self.source: msg = f"The Account Sender ID specified ({source}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Burst SMS Notification.""" if not self.targets: self.logger.warning( "There are no valid Burst SMS targets to notify." ) return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Accept": "application/json", } # Prepare our authentication auth = (self.apikey, self.secret) # Prepare our payload payload = { "countrycode": self.country, "message": body, # Sender ID "from": self.source, # The to gets populated in the loop below "to": None, } # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size # Create a copy of the targets list targets = list(self.targets) for index in range(0, len(targets), batch_size): # Prepare our user payload["to"] = ",".join(self.targets[index : index + batch_size]) # Some Debug Logging self.logger.debug( "Burst SMS POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Burst SMS Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyBurstSMS.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Burst SMS notification to {} " "target(s): {}{}error={}.".format( len(self.targets[index : index + batch_size]), status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info( "Sent Burst SMS notification to " f"{len(self.targets[index : index + batch_size])} " "target(s)." ) except requests.RequestException as e: self.logger.warning( f"A Connection error occurred sending Burst SMS " "notification to " f"{len(self.targets[index : index + batch_size])} " "target(s)." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "country": self.country, "batch": "yes" if self.batch else "no", } if self.validity: params["validity"] = str(self.validity) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{key}:{secret}@{source}/{targets}/?{params}".format( schema=self.secure_protocol, key=self.pprint(self.apikey, privacy, safe=""), secret=self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ), source=NotifyBurstSMS.quote(self.source, safe=""), targets="/".join( [NotifyBurstSMS.quote(x, safe="") for x in self.targets] ), params=NotifyBurstSMS.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.apikey, self.secret, self.source) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The hostname is our source (Sender ID) results["source"] = NotifyBurstSMS.unquote(results["host"]) # Get any remaining targets results["targets"] = NotifyBurstSMS.split_path(results["fullpath"]) # Get our account_side and auth_token from the user/pass config results["apikey"] = NotifyBurstSMS.unquote(results["user"]) results["secret"] = NotifyBurstSMS.unquote(results["password"]) # API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): # Extract the API Key from an argument results["apikey"] = NotifyBurstSMS.unquote(results["qsd"]["key"]) # API Secret if "secret" in results["qsd"] and len(results["qsd"]["secret"]): # Extract the API Secret from an argument results["secret"] = NotifyBurstSMS.unquote( results["qsd"]["secret"] ) # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyBurstSMS.unquote(results["qsd"]["from"]) if "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyBurstSMS.unquote( results["qsd"]["source"] ) # Support country if "country" in results["qsd"] and len(results["qsd"]["country"]): results["country"] = NotifyBurstSMS.unquote( results["qsd"]["country"] ) # Support validity value if "validity" in results["qsd"] and len(results["qsd"]["validity"]): results["validity"] = NotifyBurstSMS.unquote( results["qsd"]["validity"] ) # Get Batch Mode Flag if "batch" in results["qsd"] and len(results["qsd"]["batch"]): results["batch"] = parse_bool(results["qsd"]["batch"]) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyBurstSMS.parse_phone_no( results["qsd"]["to"] ) return results ================================================ FILE: apprise/plugins/chanify.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # Chanify # 1. Visit https://chanify.net/ # The API URL will look something like this: # https://api.chanify.net/v1/sender/token # import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import validate_regex from .base import NotifyBase class NotifyChanify(NotifyBase): """A wrapper for Chanify Notifications.""" # The default descriptive name associated with the Notification service_name = _("Chanify") # The services URL service_url = "https://chanify.net/" # The default secure protocol secure_protocol = "chanify" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/chanify/" # Notification URL notify_url = "https://api.chanify.net/v1/sender/{token}/" # Define object templates templates = ("{schema}://{token}",) # The title is not used title_maxlen = 0 # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, "regex": (r"^[A-Z0-9._-]+$", "i"), }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "token": { "alias_of": "token", }, }, ) def __init__(self, token, **kwargs): """Initialize Chanify Object.""" super().__init__(**kwargs) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"The Chanify token specified ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Send our notification.""" # prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/x-www-form-urlencoded", } # Our Message payload = {"text": body} self.logger.debug( "Chanify GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Chanify Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url.format(token=self.token), data=payload, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyChanify.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Chanify notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Return; we're done return False else: self.logger.info("Sent Chanify notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Chanify notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Prepare our parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{token}/?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), params=NotifyChanify.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.token) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" # parse_url already handles getting the `user` and `password` fields # populated. results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Allow over-ride if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyChanify.unquote(results["qsd"]["token"]) else: results["token"] = NotifyChanify.unquote(results["host"]) return results ================================================ FILE: apprise/plugins/clickatell.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from itertools import chain # To use this service you will need a Clickatell account to which you can get # your API_TOKEN at: # https://www.clickatell.com/ import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import is_phone_no, parse_phone_no, validate_regex from .base import NotifyBase class NotifyClickatell(NotifyBase): """A wrapper for Clickatell Notifications.""" # The default descriptive name associated with the Notification service_name = _("Clickatell") # The services URL service_url = "https://www.clickatell.com/" # All notification requests are secure secure_protocol = "clickatell" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/clickatell/" # Clickatell API Endpoint notify_url = "https://platform.clickatell.com/messages/http/send" # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 templates = ( "{schema}://{apikey}/{targets}", "{schema}://{source}@{apikey}/{targets}", ) template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Token"), "type": "string", "private": True, "required": True, }, "source": { "name": _("From Phone No"), "type": "string", "regex": (r"^[0-9\s)(+-]+$", "i"), }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) template_args = dict( NotifyBase.template_args, **{ "apikey": {"alias_of": "apikey"}, "to": { "alias_of": "targets", }, "from": { "alias_of": "source", }, }, ) def __init__(self, apikey, source=None, targets=None, **kwargs): """Initialize Clickatell Object.""" super().__init__(**kwargs) self.apikey = validate_regex(apikey) if not self.apikey: msg = f"An invalid Clickatell API Token ({apikey}) was specified." self.logger.warning(msg) raise TypeError(msg) self.source = None if source: result = is_phone_no(source) if not result: msg = ( "The Account (From) Phone # specified " f"({source}) is invalid." ) self.logger.warning(msg) raise TypeError(msg) # Tidy source self.source = result["full"] # Used for URL generation afterwards only self._invalid_targets = [] # Parse our targets self.targets = [] for target in parse_phone_no(targets, prefix=True): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) self._invalid_targets.append(target) continue # store valid phone number self.targets.append(result["full"]) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.apikey, self.source) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" params = self.url_parameters(privacy=privacy, *args, **kwargs) return "{schema}://{source}{apikey}/{targets}/?{params}".format( schema=self.secure_protocol, source=f"{self.source}@" if self.source else "", apikey=self.pprint(self.apikey, privacy, safe="="), targets="/".join([ NotifyClickatell.quote(t, safe="") for t in chain(self.targets, self._invalid_targets) ]), params=self.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification. Always return 1 at least """ return len(self.targets) if self.targets else 1 def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Clickatell Notification.""" if not self.targets: # There were no targets to notify self.logger.warning("There were no Clickatell targets to notify") return False headers = { "User-Agent": self.app_id, "Accept": "application/json", "Content-Type": "application/json", } params_base = { "apiKey": self.apikey, "from": self.source, "content": body, } # error tracking (used for function return) has_error = False for target in self.targets: params = params_base.copy() params["to"] = target # Some Debug Logging self.logger.debug( "Clickatell GET URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"Clickatell Payload: {params}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.get( self.notify_url, params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if ( r.status_code != requests.codes.ok and r.status_code != requests.codes.accepted ): # We had a problem status_str = self.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send Clickatell notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info( "Sent Clickatell notification to %s", target ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Clickatell: to %s ", target, ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't parse the URL return results results["targets"] = NotifyClickatell.split_path(results["fullpath"]) results["apikey"] = NotifyClickatell.unquote(results["host"]) if results["user"]: results["source"] = NotifyClickatell.unquote(results["user"]) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyClickatell.parse_phone_no( results["qsd"]["to"] ) # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyClickatell.unquote( results["qsd"]["from"] ) return results ================================================ FILE: apprise/plugins/clicksend.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, simply signup with clicksend: # https://www.clicksend.com/ # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - clicksend://{user}:{password}@{phoneno} # - clicksend://{user}:{password}@{phoneno1}/{phoneno2} # The API reference used to build this plugin was documented here: # https://developers.clicksend.com/docs/rest/v3/ # from json import dumps import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_phone_no, parse_bool, parse_phone_no from .base import NotifyBase # Extend HTTP Error Messages CLICKSEND_HTTP_ERROR_MAP = { 401: "Unauthorized - Invalid Token.", } class NotifyClickSend(NotifyBase): """A wrapper for ClickSend Notifications.""" # The default descriptive name associated with the Notification service_name = "ClickSend" # The services URL service_url = "https://clicksend.com/" # The default secure protocol secure_protocol = "clicksend" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/clicksend/" # ClickSend uses the http protocol with JSON requests notify_url = "https://rest.clicksend.com/v3/sms/send" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # The maximum SMS batch size accepted by the ClickSend API default_batch_size = 1000 # Define object templates templates = ("{schema}://{user}:{apikey}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "apikey": { "name": _("API Key"), "type": "string", "private": True, "required": True, "map_to": "password", }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "key": { "alias_of": "apikey", }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__(self, targets=None, batch=False, **kwargs): """Initialize ClickSend Object.""" super().__init__(**kwargs) # Prepare Batch Mode Flag self.batch = batch # Parse our targets self.targets = [] if not (self.user and self.password): msg = "A ClickSend user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform ClickSend Notification.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no ClickSend targets to notify.") return False headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # error tracking (used for function return) has_error = False # prepare JSON Object payload = {"messages": []} # Send in batches if identified to do so default_batch_size = 1 if not self.batch else self.default_batch_size for index in range(0, len(self.targets), default_batch_size): payload["messages"] = [ { "source": "php", "body": body, "to": f"+{to}", } for to in self.targets[index : index + default_batch_size] ] self.logger.debug( "ClickSend POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"ClickSend Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), auth=(self.user, self.password), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyClickSend.http_response_code_lookup( r.status_code, CLICKSEND_HTTP_ERROR_MAP ) self.logger.warning( "Failed to send {} ClickSend notification{}: " "{}{}error={}.".format( len(payload["messages"]), ( f" to {self.targets[index]}" if default_batch_size == 1 else "(s)" ), status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: self.logger.info( "Sent {} ClickSend notification{}.".format( len(payload["messages"]), ( f" to {self.targets[index]}" if default_batch_size == 1 else "(s)" ), ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending {} ClickSend " "notification(s).".format(len(payload["messages"])) ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyClickSend.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( [NotifyClickSend.quote(x, safe="") for x in self.targets] ), params=NotifyClickSend.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.user, self.password) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyClickSend.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend( NotifyClickSend.split_path(results["fullpath"]) ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # API Key if "key" in results["qsd"] and len(results["qsd"]["key"]): # Extract the API Key from an argument results["password"] = NotifyClickSend.unquote( results["qsd"]["key"] ) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyClickSend.parse_phone_no( results["qsd"]["to"] ) return results ================================================ FILE: apprise/plugins/custom_form.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from .base import NotifyBase class FORMPayloadField: """Identifies the fields available in the FORM Payload.""" VERSION = "version" TITLE = "title" MESSAGE = "message" MESSAGETYPE = "type" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS") class NotifyForm(NotifyBase): """A wrapper for Form Notifications.""" # Support # - file* # - file? # - file*name # - file?name # - ?file # - *file # - file # The code will convert the ? or * to the digit increments __attach_as_re = re.compile( r"((?P(?P[a-z0-9_-]+)?" r"(?P[*?+$:.%]+)(?P[a-z0-9_-]+))" r"|(?P(?P[a-z0-9_-]+)(?P[*?+$:.%]?)))", re.IGNORECASE, ) # Our count attach_as_count = "{:02d}" # the default attach_as value attach_as_default = f"file{attach_as_count}" # The default descriptive name associated with the Notification service_name = "Form" # The default protocol protocol = "form" # The default secure protocol secure_protocol = "forms" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/form/" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for Form requests since they are normally # local anyway request_rate_per_sec = 0 # Define the FORM version to place in all payloads # Version: Major.Minor, Major is only updated if the entire schema is # changed. If just adding new items (or removing old ones, only increment # the Minor! form_version = "1.0" # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, "attach-as": { "name": _("Attach File As"), "type": "string", "default": "file*", "map_to": "attach_as", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, attach_as=None, **kwargs, ): """Initialize Form Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Custom File Attachment Over-Ride Support if not isinstance(attach_as, str): # Default value self.attach_as = self.attach_as_default self.attach_multi_support = True else: result = self.__attach_as_re.match(attach_as.strip()) if not result: msg = f"The attach-as specified ({attach_as}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.attach_as = "" self.attach_multi_support = False if result.group("match1"): if result.group("id1a"): self.attach_as += result.group("id1a") self.attach_as += self.attach_as_count self.attach_multi_support = True self.attach_as += result.group("id1b") else: # result.group('match2'): self.attach_as += result.group("id2") if result.group("wc2"): self.attach_as += self.attach_as_count self.attach_multi_support = True # A payload map allows users to over-ride the default mapping if # they're detected with the :overide=value. Normally this would # create a new key and assign it the value specified. However # if the key you specify is actually an internally mapped one, # then a re-mapping takes place using the value self.payload_map = { FORMPayloadField.VERSION: FORMPayloadField.VERSION, FORMPayloadField.TITLE: FORMPayloadField.TITLE, FORMPayloadField.MESSAGE: FORMPayloadField.MESSAGE, FORMPayloadField.MESSAGETYPE: FORMPayloadField.MESSAGETYPE, } self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) for key in list(self.payload_extras.keys()): # Any values set in the payload to alter a system related one # alters the system key. Hence :message=msg maps the 'message' # variable that otherwise already contains the payload to be # 'msg' instead (containing the payload) if key in self.payload_map: self.payload_map[key] = self.payload_extras[key] self.payload_overrides[key] = self.payload_extras[key] del self.payload_extras[key] return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Form Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, } # Apply any/all header over-rides defined headers.update(self.headers) # Track our potential attachments files = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access attachment" f" {attachment.url(privacy=True)}." ) return False try: files.append(( ( self.attach_as.format(no) if self.attach_multi_support else self.attach_as ), ( ( attachment.name if attachment.name else f"file{no:03}.dat" ), # file handle is safely closed in `finally`; inline # open is intentional open(attachment.path, "rb"), # noqa: SIM115 attachment.mimetype, ), )) except OSError as e: self.logger.warning( "An I/O error occurred while opening {}.".format( attachment.name if attachment else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False if not self.attach_multi_support and no > 1: self.logger.warning( "Multiple attachments provided while " "form:// Multi-Attachment Support not enabled" ) # prepare Form Object payload = {} for key, value in ( (FORMPayloadField.VERSION, self.form_version), (FORMPayloadField.TITLE, title), (FORMPayloadField.MESSAGE, body), (FORMPayloadField.MESSAGETYPE, notify_type.value), ): if not self.payload_map[key]: # Do not store element in payload response continue payload[self.payload_map[key]] = value # Apply any/all payload over-rides defined payload.update(self.payload_extras) auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath self.logger.debug( f"Form {self.method} URL:" f" {url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Form Payload: {payload!s}") # Always call throttle before any remote server i/o is made self.throttle() # For GET the payload becomes URL query parameters; for all other # methods it is sent as the request body. if self.method == "GET": payload.update(self.params) try: r = requests.request( self.method, url, files=files if files else None, data=payload if self.method != "GET" else None, params=payload if self.method == "GET" else self.params, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyForm.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send Form %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Return; we're done return False else: self.logger.info("Sent Form %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Form " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False except OSError as e: self.logger.warning( "An I/O error occurred while reading one of the " "attached files." ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: for file in files: # Ensure all files are closed file[1][1].close() return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) params.update({f":{k}": v for k, v in self.payload_overrides.items()}) if self.attach_as != self.attach_as_default: # Provide Attach-As extension details params["attach-as"] = self.attach_as # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyForm.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyForm.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyForm.quote(self.fullpath, safe="/") if self.fullpath else "/" ), params=NotifyForm.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyForm.unquote(x): NotifyForm.unquote(y) for x, y in results["qsd-"].items() } # Allow Attach-As Support which over-rides the name of the filename # posted with the form:// # the default is file01, file02, file03, etc if "attach-as" in results["qsd"] and len(results["qsd"]["attach-as"]): results["attach_as"] = results["qsd"]["attach-as"] # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyForm.unquote(results["qsd"]["method"]) return results ================================================ FILE: apprise/plugins/custom_json.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from json import dumps import logging import requests from .. import exception from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.sanitize import sanitize_payload from .base import NotifyBase class JSONPayloadField: """Identifies the fields available in the JSON Payload.""" VERSION = "version" TITLE = "title" MESSAGE = "message" ATTACHMENTS = "attachments" MESSAGETYPE = "type" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS") class NotifyJSON(NotifyBase): """A wrapper for JSON Notifications.""" # The default descriptive name associated with the Notification service_name = "JSON" # The default protocol protocol = "json" # The default secure protocol secure_protocol = "jsons" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/json/" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for JSON requests since they are normally # local anyway request_rate_per_sec = 0 # Define the JSON version to place in all payloads # Version: Major.Minor, Major is only updated if the entire schema is # changed. If just adding new items (or removing old ones, only increment # the Minor! json_version = "1.0" # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, **kwargs ): """Initialize JSON Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform JSON Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Apply any/all header over-rides defined headers.update(self.headers) # Track our potential attachments attachments = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Custom JSON attachment" f" {attachment.url(privacy=True)}." ) return False try: attachments.append({ "filename": ( attachment.name if attachment.name else f"file{no:03}.dat" ), "base64": attachment.base64(), "mimetype": attachment.mimetype, }) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Custom JSON attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Custom JSON attachment" f" {attachment.url(privacy=True)}" ) # Prepare JSON Object payload = { JSONPayloadField.VERSION: self.json_version, JSONPayloadField.TITLE: title, JSONPayloadField.MESSAGE: body, JSONPayloadField.ATTACHMENTS: attachments, JSONPayloadField.MESSAGETYPE: notify_type.value, } for key, value in self.payload_extras.items(): if key in payload: if not value: # Do not store element in payload response del payload[key] else: # Re-map payload[value] = payload[key] del payload[key] else: # Append entry payload[key] = value auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( f"JSON POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug("JSON Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.request( self.method, url, data=dumps(payload), params=self.params, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyJSON.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send JSON %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Return; we're done return False else: self.logger.info("Sent JSON %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending JSON " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyJSON.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyJSON.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyJSON.quote(self.fullpath, safe="/") if self.fullpath else "/" ), params=NotifyJSON.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyJSON.unquote(x): NotifyJSON.unquote(y) for x, y in results["qsd-"].items() } # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyJSON.unquote(results["qsd"]["method"]) return results ================================================ FILE: apprise/plugins/custom_xml.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import logging import re import requests from .. import exception from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.sanitize import sanitize_payload from .base import NotifyBase class XMLPayloadField: """Identifies the fields available in the JSON Payload.""" VERSION = "Version" TITLE = "Subject" MESSAGE = "Message" MESSAGETYPE = "MessageType" # Defines the method to send the notification METHODS = ( "POST", "GET", "DELETE", "PUT", "HEAD", "PATCH", "UPDATE", "OPTIONS") class NotifyXML(NotifyBase): """A wrapper for XML Notifications.""" # The default descriptive name associated with the Notification service_name = "XML" # The default protocol protocol = "xml" # The default secure protocol secure_protocol = "xmls" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/xml/" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Disable throttle rate for JSON requests since they are normally # local anyway request_rate_per_sec = 0 # XSD Information xsd_ver = "1.1" xsd_default_url = ( "https://raw.githubusercontent.com/caronc/apprise/master" "/apprise/assets/NotifyXML-{version}.xsd" ) # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}:{port}", "{schema}://{user}@{host}", "{schema}://{user}@{host}:{port}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}:{port}", ) # Define our tokens; these are the minimum tokens required required to # be passed into this function (as arguments). The syntax appends any # previously defined in the base package and builds onto them template_tokens = dict( NotifyBase.template_tokens, **{ "host": { "name": _("Hostname"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "user": { "name": _("Username"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "method": { "name": _("Fetch Method"), "type": "choice:string", "values": METHODS, "default": METHODS[0], }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("HTTP Header"), "prefix": "+", }, "payload": { "name": _("Payload Extras"), "prefix": ":", }, "params": { "name": _("GET Params"), "prefix": "-", }, } def __init__( self, headers=None, method=None, payload=None, params=None, **kwargs ): """Initialize XML Object. headers can be a dictionary of key/value pairs that you want to additionally include as part of the server headers to post with """ super().__init__(**kwargs) self.payload = """ {{CORE}} {{ATTACHMENTS}} """ self.fullpath = kwargs.get("fullpath") if not isinstance(self.fullpath, str): self.fullpath = "" self.method = ( self.template_args["method"]["default"] if not isinstance(method, str) else method.upper() ) if self.method not in METHODS: msg = f"The method specified ({method}) is invalid." self.logger.warning(msg) raise TypeError(msg) # A payload map allows users to over-ride the default mapping if # they're detected with the :overide=value. Normally this would # create a new key and assign it the value specified. However # if the key you specify is actually an internally mapped one, # then a re-mapping takes place using the value self.payload_map = { XMLPayloadField.VERSION: XMLPayloadField.VERSION, XMLPayloadField.TITLE: XMLPayloadField.TITLE, XMLPayloadField.MESSAGE: XMLPayloadField.MESSAGE, XMLPayloadField.MESSAGETYPE: XMLPayloadField.MESSAGETYPE, } self.params = {} if params: # Store our extra headers self.params.update(params) self.headers = {} if headers: # Store our extra headers self.headers.update(headers) self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries (but tidy them up since they will # become XML Keys (they can't contain certain characters for k, v in payload.items(): key = re.sub(r"[^A-Za-z0-9_-]*", "", k) if not key: self.logger.warning( f"Ignoring invalid XML Stanza element name({k})" ) continue # Any values set in the payload to alter a system related one # alters the system key. Hence :message=msg maps the 'message' # variable that otherwise already contains the payload to be # 'msg' instead (containing the payload) if key in self.payload_map: self.payload_map[key] = v self.payload_overrides[key] = v else: self.payload_extras[key] = v # Set our xsd url self.xsd_url = ( None if self.payload_overrides or self.payload_extras else self.xsd_default_url.format(version=self.xsd_ver) ) return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform XML Notification.""" # Prepare HTTP Headers headers = { "User-Agent": self.app_id, "Content-Type": "application/xml", } # Apply any/all header over-rides defined headers.update(self.headers) # Our XML Attachmement subsitution xml_attachments = "" payload_base = {} for key, value in ( (XMLPayloadField.VERSION, self.xsd_ver), ( XMLPayloadField.TITLE, NotifyXML.escape_html(title, whitespace=False), ), ( XMLPayloadField.MESSAGE, NotifyXML.escape_html(body, whitespace=False), ), ( XMLPayloadField.MESSAGETYPE, NotifyXML.escape_html(notify_type.value, whitespace=False), ), ): if not self.payload_map[key]: # Do not store element in payload response continue payload_base[self.payload_map[key]] = value # Apply our payload extras payload_base.update({ k: NotifyXML.escape_html(v, whitespace=False) for k, v in self.payload_extras.items() }) # Base Entres xml_base = "".join( [f"<{k}>{v}" for k, v in payload_base.items()] ) attachments = [] if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( "Could not access Custom XML attachment" f" {attachment.url(privacy=True)}." ) return False try: # Prepare our Attachment in Base64 entry = ''.format( NotifyXML.escape_html( ( attachment.name if attachment.name else f"file{no:03}.dat" ), whitespace=False, ), NotifyXML.escape_html( attachment.mimetype, whitespace=False ), ) entry += attachment.base64() entry += "" attachments.append(entry) except exception.AppriseException: # We could not access the attachment self.logger.error( "Could not access Custom XML attachment" f" {attachment.url(privacy=True)}." ) return False self.logger.debug( "Appending Custom XML attachment" f" {attachment.url(privacy=True)}" ) # Update our xml_attachments record: xml_attachments = ( '' + "".join(attachments) + "" ) re_map = { "{{XSD_URL}}": ( f' xmlns:xsi="{self.xsd_url}"' if self.xsd_url else "" ), "{{ATTACHMENTS}}": xml_attachments, "{{CORE}}": xml_base, } # Iterate over above list and store content accordingly re_table = re.compile( r"(" + "|".join(re_map.keys()) + r")", re.IGNORECASE, ) auth = None if self.user: auth = (self.user, self.password) # Set our schema schema = "https" if self.secure else "http" url = f"{schema}://{self.host}" if isinstance(self.port, int): url += f":{self.port}" url += self.fullpath payload = re_table.sub(lambda x: re_map[x.group()], self.payload) # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( f"XML POST URL: {url} " f"(cert_verify={self.verify_certificate!r})" ) self.logger.debug( "XML Payload: %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.request( self.method, url, data=payload, headers=headers, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code < 200 or r.status_code >= 300: # We had a problem status_str = NotifyXML.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send JSON %s notification: %s%serror=%s.", self.method, status_str, ", " if status_str else "", r.status_code, ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Return; we're done return False else: self.logger.info("Sent XML %s notification.", self.method) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending XML " f"notification to {self.host}." ) self.logger.debug(f"Socket Exception: {e!s}") # Return; we're done return False return True @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.port if self.port else (443 if self.secure else 80), self.fullpath.rstrip("/"), ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "method": self.method, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({f"+{k}": v for k, v in self.headers.items()}) # Append our GET params into our parameters params.update({f"-{k}": v for k, v in self.params.items()}) # Append our payload extra's into our parameters params.update({f":{k}": v for k, v in self.payload_extras.items()}) params.update({f":{k}": v for k, v in self.payload_overrides.items()}) # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyXML.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif self.user: auth = "{user}@".format( user=NotifyXML.quote(self.user, safe=""), ) default_port = 443 if self.secure else 80 return "{schema}://{auth}{hostname}{port}{fullpath}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else f":{self.port}" ), fullpath=( NotifyXML.quote(self.fullpath, safe="/") if self.fullpath else "/" ), params=NotifyXML.urlencode(params), ) @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # store any additional payload extra's defined results["payload"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd:"].items() } # Add our headers that the user can potentially over-ride if they wish # to to our returned result set and tidy entries by unquoting them results["headers"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd+"].items() } # Add our GET paramters in the event the user wants to pass these along results["params"] = { NotifyXML.unquote(x): NotifyXML.unquote(y) for x, y in results["qsd-"].items() } # Set method if not otherwise set if "method" in results["qsd"] and len(results["qsd"]["method"]): results["method"] = NotifyXML.unquote(results["qsd"]["method"]) return results ================================================ FILE: apprise/plugins/d7networks.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this service you will need a D7 Networks account from their website # at https://d7networks.com/ # # After you've established your account you can get your api login credentials # (both user and password) from the API Details section from within your # account profile area: https://d7networks.com/accounts/profile/ # # API Reference: https://d7networks.com/docs/Messages/Send_Message/ from json import dumps, loads import requests from ..common import NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import ( is_phone_no, parse_bool, parse_phone_no, validate_regex, ) from .base import NotifyBase # Extend HTTP Error Messages D7NETWORKS_HTTP_ERROR_MAP = { 401: "Invalid Argument(s) Specified.", 403: "Unauthorized - Authentication Failure.", 412: "A Routing Error Occured", 500: "A Serverside Error Occured Handling the Request.", } class NotifyD7Networks(NotifyBase): """A wrapper for D7 Networks Notifications.""" # The default descriptive name associated with the Notification service_name = "D7 Networks" # The services URL service_url = "https://d7networks.com/" # All notification requests are secure secure_protocol = "d7sms" # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.20 # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/d7networks/" # D7 Networks single notification URL notify_url = "https://api.d7networks.com/messages/v1/send" # The maximum length of the body body_maxlen = 160 # A title can not be used for SMS Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # Define object templates templates = ("{schema}://{token}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("API Access Token"), "type": "string", "required": True, "private": True, }, "target_phone": { "name": _("Target Phone No"), "type": "string", "prefix": "+", "regex": (r"^[0-9\s)(+-]+$", "i"), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "source": { # Originating address,In cases where the rewriting of the # sender's address is supported or permitted by the SMS-C. # This is used to transmit the message, this number is # transmitted as the originating address and is completely # optional. "name": _("Originating Address"), "type": "string", "map_to": "source", }, "from": { "alias_of": "source", }, "unicode": { # Unicode characters (default is 'auto') "name": _("Unicode Characters"), "type": "bool", "default": False, }, "to": { "alias_of": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, token=None, targets=None, source=None, batch=False, unicode=None, **kwargs, ): """Initialize D7 Networks Object.""" super().__init__(**kwargs) # Prepare Batch Mode Flag self.batch = batch # Setup our source address (if defined) self.source = None if not isinstance(source, str) else source.strip() # Define whether or not we should set the unicode flag self.unicode = ( self.template_args["unicode"]["default"] if unicode is None else bool(unicode) ) # The token associated with the account self.token = validate_regex(token) if not self.token: msg = f"The D7 Networks token specified ({token}) is invalid." self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = [] for target in parse_phone_no(targets): # Validate targets and drop bad ones: result = result = is_phone_no(target) if not result: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result["full"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Depending on whether we are set to batch mode or single mode this redirects to the appropriate handling.""" if len(self.targets) == 0: # There were no services to notify self.logger.warning("There were no D7 Networks targets to notify.") return False # error tracking (used for function return) has_error = False # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", "Accept": "application/json", "Authorization": f"Bearer {self.token}", } payload = { "message_globals": { "channel": "sms", }, "messages": [{ # Populated later on "recipients": None, "content": body, "data_coding": # auto is a better substitute over 'text' as text is easier to # detect from a post than `unicode` is. "auto" if not self.unicode else "unicode", }], } # use the list directly targets = list(self.targets) if self.source: payload["message_globals"]["originator"] = self.source target = None while len(targets): if self.batch: # Prepare our payload payload["messages"][0]["recipients"] = self.targets # Reset our targets so we don't keep going. This is required # because we're in batch mode; we only need to loop once. targets = [] else: # We're not in a batch mode; so get our next target # Get our target(s) to notify target = targets.pop(0) # Prepare our payload payload["messages"][0]["recipients"] = [target] # Some Debug Logging self.logger.debug( "D7 Networks POST URL:" f" {self.notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"D7 Networks Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.created, requests.codes.ok, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code, D7NETWORKS_HTTP_ERROR_MAP ) try: # Update our status response if we can json_response = loads(r.content) status_str = json_response.get("message", status_str) except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None # AttributeError = r is None # We could not parse JSON response. # We will just use the status we already have. pass self.logger.warning( "Failed to send D7 Networks SMS notification to {}: " "{}{}error={}.".format( ", ".join(target) if self.batch else target, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True continue else: if self.batch: self.logger.info( "Sent D7 Networks batch SMS notification to " f"{len(self.targets)} target(s)." ) else: self.logger.info( f"Sent D7 Networks SMS notification to {target}." ) self.logger.debug(f"Response Details:\r\n{r.content}") except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending D7 Networks:{} " .format(", ".join(self.targets)) + "notification." ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True continue return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "batch": "yes" if self.batch else "no", "unicode": "yes" if self.unicode else "no", } if self.source: params["from"] = self.source # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return "{schema}://{token}@{targets}/?{params}".format( schema=self.secure_protocol, token=self.pprint(self.token, privacy, safe=""), targets="/".join( [NotifyD7Networks.quote(x, safe="") for x in self.targets] ), params=NotifyD7Networks.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.token) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # return len(self.targets) if not self.batch else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyD7Networks.unquote( results["qsd"]["token"] ) elif results["user"]: results["token"] = NotifyD7Networks.unquote(results["user"]) if results["password"]: # Support token containing a colon (:) results["token"] += ":" + NotifyD7Networks.unquote( results["password"] ) elif results["password"]: # Support token starting with a colon (:) results["token"] = ":" + NotifyD7Networks.unquote( results["password"] ) # Initialize our targets results["targets"] = [] # The store our first target stored in the hostname results["targets"].append(NotifyD7Networks.unquote(results["host"])) # Get our entries; split_path() looks after unquoting content for us # by default results["targets"].extend( NotifyD7Networks.split_path(results["fullpath"]) ) # Get Batch Mode Flag results["batch"] = parse_bool(results["qsd"].get("batch", False)) # Get Unicode Flag results["unicode"] = parse_bool(results["qsd"].get("unicode", False)) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyD7Networks.parse_phone_no( results["qsd"]["to"] ) # Support the 'from' and source variable if "from" in results["qsd"] and len(results["qsd"]["from"]): results["source"] = NotifyD7Networks.unquote( results["qsd"]["from"] ) elif "source" in results["qsd"] and len(results["qsd"]["source"]): results["source"] = NotifyD7Networks.unquote( results["qsd"]["source"] ) return results ================================================ FILE: apprise/plugins/dapnet.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # To use this plugin, sign up with Hampager (you need to be a licensed # ham radio operator # http://www.hampager.de/ # # You're done at this point, you only need to know your user/pass that # you signed up with. # The following URLs would be accepted by Apprise: # - dapnet://{user}:{password}@{callsign} # - dapnet://{user}:{password}@{callsign1}/{callsign2} # Optional parameters: # - priority (NORMAL or EMERGENCY). Default: NORMAL # - txgroups --> comma-separated list of DAPNET transmitter # groups. Default: 'dl-all' # https://hampager.de/#/transmitters/groups from json import dumps # The API reference used to build this plugin was documented here: # https://hampager.de/dokuwiki/doku.php#dapnet_api # import requests from requests.auth import HTTPBasicAuth from ..common import NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import is_call_sign, parse_bool, parse_call_sign, parse_list from .base import NotifyBase class DapnetPriority: NORMAL = 0 EMERGENCY = 1 DAPNET_PRIORITIES = { DapnetPriority.NORMAL: "normal", DapnetPriority.EMERGENCY: "emergency", } DAPNET_PRIORITY_MAP = { # Maps against string 'normal' "n": DapnetPriority.NORMAL, # Maps against string 'emergency' "e": DapnetPriority.EMERGENCY, # Entries to additionally support (so more like Dapnet's API) "0": DapnetPriority.NORMAL, "1": DapnetPriority.EMERGENCY, } class NotifyDapnet(NotifyBase): """A wrapper for DAPNET / Hampager Notifications.""" # The default descriptive name associated with the Notification service_name = "Dapnet" # The services URL service_url = "https://hampager.de/" # The default secure protocol secure_protocol = "dapnet" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dapnet/" # Dapnet uses the http protocol with JSON requests notify_url = "http://www.hampager.de:8080/calls" # The maximum length of the body body_maxlen = 80 # A title can not be used for Dapnet Messages. Setting this to zero will # cause any title (if defined) to get placed into the message body. title_maxlen = 0 # The maximum amount of emails that can reside within a single transmission default_batch_size = 50 # Define object templates templates = ("{schema}://{user}:{password}@{targets}",) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", "required": True, }, "password": { "name": _("Password"), "type": "string", "private": True, "required": True, }, "target_callsign": { "name": _("Target Callsign"), "type": "string", "regex": ( r"^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$", "i", ), "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "priority": { "name": _("Priority"), "type": "choice:int", "values": DAPNET_PRIORITIES, "default": DapnetPriority.NORMAL, }, "txgroups": { "name": _("Transmitter Groups"), "type": "string", "default": "dl-all", "private": True, }, "to": { "name": _("Target Callsign"), "type": "string", "map_to": "targets", }, "batch": { "name": _("Batch Mode"), "type": "bool", "default": False, }, }, ) def __init__( self, targets=None, priority=None, txgroups=None, batch=False, **kwargs ): """Initialize Dapnet Object.""" super().__init__(**kwargs) # Parse our targets self.targets = [] # The Priority of the message self.priority = int( NotifyDapnet.template_args["priority"]["default"] if priority is None else next( ( v for k, v in DAPNET_PRIORITY_MAP.items() if str(priority).lower().startswith(k) ), NotifyDapnet.template_args["priority"]["default"], ) ) if not (self.user and self.password): msg = "A Dapnet user/pass was not provided." self.logger.warning(msg) raise TypeError(msg) # Get the transmitter group self.txgroups = parse_list( txgroups if txgroups else NotifyDapnet.template_args["txgroups"]["default"] ) # Prepare Batch Mode Flag self.batch = batch for target in parse_call_sign(targets): # Validate targets and drop bad ones: result = is_call_sign(target) if not result: self.logger.warning( f"Dropping invalid Amateur radio call sign ({target}).", ) continue # Store callsign without SSID and ignore duplicates if result["callsign"] not in self.targets: self.targets.append(result["callsign"]) return def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform Dapnet Notification.""" if not self.targets: # There is no one to email; we're done self.logger.warning( "There are no Amateur radio callsigns to notify" ) return False # Send in batches if identified to do so batch_size = 1 if not self.batch else self.default_batch_size headers = { "User-Agent": self.app_id, "Content-Type": "application/json; charset=utf-8", } # error tracking (used for function return) has_error = False # Create a copy of the targets list targets = list(self.targets) for index in range(0, len(targets), batch_size): # prepare JSON payload payload = { "text": body, "callSignNames": targets[index : index + batch_size], "transmitterGroupNames": self.txgroups, "emergency": self.priority == DapnetPriority.EMERGENCY, } self.logger.debug(f"DAPNET POST URL: {self.notify_url}") self.logger.debug(f"DAPNET Payload: {dumps(payload)}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( self.notify_url, data=dumps(payload), headers=headers, auth=HTTPBasicAuth( username=self.user, password=self.password ), verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.created: # We had a problem self.logger.warning( "Failed to send DAPNET notification {} to {}: " "error={}.".format( payload["text"], f" to {self.targets}", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Mark our failure has_error = True else: self.logger.info( "Sent '{}' DAPNET notification {}".format( payload["text"], f"to {self.targets}" ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending DAPNET " f"notification to {self.targets}" ) self.logger.debug(f"Socket Exception: {e!s}") # Mark our failure has_error = True return not has_error def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "priority": ( DAPNET_PRIORITIES[self.template_args["priority"]["default"]] if self.priority not in DAPNET_PRIORITIES else DAPNET_PRIORITIES[self.priority] ), "batch": "yes" if self.batch else "no", "txgroups": ",".join(self.txgroups), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Setup Authentication auth = "{user}:{password}@".format( user=NotifyDapnet.quote(self.user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) return "{schema}://{auth}{targets}?{params}".format( schema=self.secure_protocol, auth=auth, targets="/".join( [self.pprint(x, privacy, safe="") for x in self.targets] ), params=NotifyDapnet.urlencode(params), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.user, self.password) def __len__(self): """Returns the number of targets associated with this notification.""" # # Factor batch into calculation # batch_size = 1 if not self.batch else self.default_batch_size targets = len(self.targets) if batch_size > 1: targets = int(targets / batch_size) + ( 1 if targets % batch_size else 0 ) return targets @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # All elements are targets results["targets"] = [NotifyDapnet.unquote(results["host"])] # All entries after the hostname are additional targets results["targets"].extend(NotifyDapnet.split_path(results["fullpath"])) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyDapnet.parse_list(results["qsd"]["to"]) # Set our priority if "priority" in results["qsd"] and len(results["qsd"]["priority"]): results["priority"] = NotifyDapnet.unquote( results["qsd"]["priority"] ) # Check for one or multiple transmitter groups (comma separated) # and split them up, when necessary if "txgroups" in results["qsd"]: results["txgroups"] = [ x.lower() for x in NotifyDapnet.parse_list(results["qsd"]["txgroups"]) ] # Get Batch Mode Flag results["batch"] = parse_bool( results["qsd"].get( "batch", NotifyDapnet.template_args["batch"]["default"] ) ) return results ================================================ FILE: apprise/plugins/dbus.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import sys from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool from .base import NotifyBase # Default our global support flag NOTIFY_DBUS_SUPPORT_ENABLED = False # Image support is dependant on the GdkPixbuf library being available NOTIFY_DBUS_IMAGE_SUPPORT = False # Initialize our mainloops LOOP_GLIB = None LOOP_QT = None try: # D-Bus Message Bus Daemon 1.12.XX Essentials from dbus import Byte, ByteArray, DBusException, Interface, SessionBus # # now we try to determine which mainloop(s) we can access # # glib/dbus try: from dbus.mainloop.glib import DBusGMainLoop LOOP_GLIB = DBusGMainLoop() except ImportError: # pragma: no cover # No problem pass # qt try: from dbus.mainloop.qt import DBusQtMainLoop LOOP_QT = DBusQtMainLoop(set_as_default=True) except ImportError: # No problem pass # We're good as long as at least one NOTIFY_DBUS_SUPPORT_ENABLED = LOOP_GLIB is not None or LOOP_QT is not None # ImportError: When using gi.repository you must not import static modules # like "gobject". Please change all occurrences of "import gobject" to # "from gi.repository import GObject". # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183 if "gobject" in sys.modules: # pragma: no cover del sys.modules["gobject"] try: # The following is required for Image/Icon loading only import gi gi.require_version("GdkPixbuf", "2.0") from gi.repository import GdkPixbuf NOTIFY_DBUS_IMAGE_SUPPORT = True except (ImportError, ValueError, AttributeError): # No problem; this will get caught in outer try/catch # A ValueError will get thrown upon calling gi.require_version() if # GDK/GTK isn't installed on the system but gi is. pass except ImportError: # No problem; we just simply can't support this plugin; we could # be in microsoft windows, or we just don't have the python-gobject # library available to us (or maybe one we don't support)? pass # Define our supported protocols and the loop to assign them. # The key to value pairs are the actual supported schema's matched # up with the Main Loop they should reference when accessed. MAINLOOP_MAP = { "qt": LOOP_QT, "kde": LOOP_QT, "dbus": LOOP_QT if LOOP_QT else LOOP_GLIB, } # Urgencies class DBusUrgency: LOW = 0 NORMAL = 1 HIGH = 2 DBUS_URGENCIES = { # Note: This also acts as a reverse lookup mapping DBusUrgency.LOW: "low", DBusUrgency.NORMAL: "normal", DBusUrgency.HIGH: "high", } DBUS_URGENCY_MAP = { # Maps against string 'low' "l": DBusUrgency.LOW, # Maps against string 'moderate' "m": DBusUrgency.LOW, # Maps against string 'normal' "n": DBusUrgency.NORMAL, # Maps against string 'high' "h": DBusUrgency.HIGH, # Maps against string 'emergency' "e": DBusUrgency.HIGH, # Entries to additionally support (so more like DBus's API) "0": DBusUrgency.LOW, "1": DBusUrgency.NORMAL, "2": DBusUrgency.HIGH, } class NotifyDBus(NotifyBase): """A wrapper for local DBus/Qt Notifications.""" # Set our global enabled flag enabled = NOTIFY_DBUS_SUPPORT_ENABLED requirements = { # Define our required packaging in order to work "details": _("libdbus-1.so.x must be installed.") } # The default descriptive name associated with the Notification service_name = _("DBus Notification") # The services URL service_url = "http://www.freedesktop.org/Software/dbus/" # The default protocols # Python 3 keys() does not return a list object, it is its own dict_keys() # object if we were to reference, we wouldn't be backwards compatible with # Python v2. So converting the result set back into a list makes us # compatible protocol = list(MAINLOOP_MAP.keys()) # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dbus/" # No throttling required for DBus queries request_rate_per_sec = 0 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # The number of milliseconds to keep the message present for message_timeout_ms = 13000 # Limit results to just the first 10 line otherwise there is just to much # content to display body_max_line_count = 10 # The following are required to hook into the notifications: dbus_interface = "org.freedesktop.Notifications" dbus_setting_location = "/org/freedesktop/Notifications" # No URL Identifier will be defined for this service as there simply isn't # enough details to uniquely identify one dbus:// from another. url_identifier = False # Define object templates templates = ("{schema}://",) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "urgency": { "name": _("Urgency"), "type": "choice:int", "values": DBUS_URGENCIES, "default": DBusUrgency.NORMAL, }, "priority": { # Apprise uses 'priority' everywhere; it's just a nice # consistent feel to be able to use it here as well. Just map # the value back to 'priority' "alias_of": "urgency", }, "x": { "name": _("X-Axis"), "type": "int", "min": 0, "map_to": "x_axis", }, "y": { "name": _("Y-Axis"), "type": "int", "min": 0, "map_to": "y_axis", }, "image": { "name": _("Include Image"), "type": "bool", "default": True, "map_to": "include_image", }, }, ) def __init__( self, urgency=None, x_axis=None, y_axis=None, include_image=True, **kwargs, ): """Initialize DBus Object.""" super().__init__(**kwargs) # Track our notifications self.registry = {} # Store our schema; default to dbus self.schema = kwargs.get("schema", "dbus") if self.schema not in MAINLOOP_MAP: msg = f"The schema specified ({self.schema}) is not supported." self.logger.warning(msg) raise TypeError(msg) # The urgency of the message self.urgency = int( NotifyDBus.template_args["urgency"]["default"] if urgency is None else next( ( v for k, v in DBUS_URGENCY_MAP.items() if str(urgency).lower().startswith(k) ), NotifyDBus.template_args["urgency"]["default"], ) ) # Our x/y axis settings if x_axis or y_axis: try: self.x_axis = int(x_axis) self.y_axis = int(y_axis) except (TypeError, ValueError): # Invalid x/y values specified msg = ( f"The x,y coordinates specified ({x_axis},{y_axis}) are" " invalid." ) self.logger.warning(msg) raise TypeError(msg) from None else: self.x_axis = None self.y_axis = None # Track whether we want to add an image to the notification. self.include_image = include_image def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform DBus Notification.""" # Acquire our session try: session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) except DBusException as e: # Handle exception self.logger.warning("Failed to send DBus notification.") self.logger.debug(f"DBus Exception: {e}") return False # If there is no title, but there is a body, swap the two to get rid # of the weird whitespace if not title: title = body body = "" # acquire our dbus object dbus_obj = session.get_object( self.dbus_interface, self.dbus_setting_location, ) # Acquire our dbus interface dbus_iface = Interface( dbus_obj, dbus_interface=self.dbus_interface, ) # image path icon_path = ( None if not self.include_image else self.image_path(notify_type, extension=".ico") ) # Our meta payload meta_payload = {"urgency": Byte(self.urgency)} if not (self.x_axis is None and self.y_axis is None): # Set x/y access if these were set meta_payload["x"] = self.x_axis meta_payload["y"] = self.y_axis if NOTIFY_DBUS_IMAGE_SUPPORT and icon_path: try: # Use Pixbuf to create the proper image type image = GdkPixbuf.Pixbuf.new_from_file(icon_path) # Associate our image to our notification meta_payload["icon_data"] = ( image.get_width(), image.get_height(), image.get_rowstride(), image.get_has_alpha(), image.get_bits_per_sample(), image.get_n_channels(), ByteArray(image.get_pixels()), ) except Exception as e: self.logger.warning( "Could not load notification icon (%s).", icon_path ) self.logger.debug(f"DBus Exception: {e}") try: # Always call throttle() before any remote execution is made self.throttle() dbus_iface.Notify( # Application Identifier self.app_id, # Message ID (0 = New Message) 0, # Icon (str) - not used "", # Title str(title), # Body str(body), # Actions [], # Meta meta_payload, # Message Timeout self.message_timeout_ms, ) self.logger.info("Sent DBus notification.") except Exception as e: self.logger.warning("Failed to send DBus notification.") self.logger.debug(f"DBus Exception: {e}") return False return True def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "image": "yes" if self.include_image else "no", "urgency": ( DBUS_URGENCIES[self.template_args["urgency"]["default"]] if self.urgency not in DBUS_URGENCIES else DBUS_URGENCIES[self.urgency] ), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # x in (x,y) screen coordinates if self.x_axis: params["x"] = str(self.x_axis) # y in (x,y) screen coordinates if self.y_axis: params["y"] = str(self.y_axis) return f"{self.schema}://_/?{NotifyDBus.urlencode(params)}" @staticmethod def parse_url(url): """There are no parameters nessisary for this protocol; simply having gnome:// is all you need. This function just makes sure that is in place. """ results = NotifyBase.parse_url(url, verify_host=False) # Include images with our message results["include_image"] = parse_bool( results["qsd"].get("image", True) ) # DBus supports urgency, but we we also support the keyword priority # so that it is consistent with some of the other plugins if "priority" in results["qsd"] and len(results["qsd"]["priority"]): # We intentionally store the priority in the urgency section results["urgency"] = NotifyDBus.unquote(results["qsd"]["priority"]) if "urgency" in results["qsd"] and len(results["qsd"]["urgency"]): results["urgency"] = NotifyDBus.unquote(results["qsd"]["urgency"]) # handle x,y coordinates if "x" in results["qsd"] and len(results["qsd"]["x"]): results["x_axis"] = NotifyDBus.unquote(results["qsd"].get("x")) if "y" in results["qsd"] and len(results["qsd"]["y"]): results["y_axis"] = NotifyDBus.unquote(results["qsd"].get("y")) return results ================================================ FILE: apprise/plugins/dingtalk.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import base64 import hashlib import hmac from json import dumps import re import time import requests from ..common import NotifyFormat, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_list, validate_regex from .base import NotifyBase # Register at https://dingtalk.com # - Download their PC based software as it is the only way you can create # a custom robot. You can create a custom robot per group. You will # be provided an access_token that Apprise will need. # Syntax: # dingtalk://{access_token}/ # dingtalk://{access_token}/{optional_phone_no} # dingtalk://{access_token}/{phone_no_1}/{phone_no_2}/{phone_no_N/ # Some Phone Number Detection IS_PHONE_NO = re.compile(r"^\+?(?P[0-9\s)(+-]+)\s*$") class NotifyDingTalk(NotifyBase): """A wrapper for DingTalk Notifications.""" # The default descriptive name associated with the Notification service_name = "DingTalk" # The services URL service_url = "https://www.dingtalk.com/" # All notification requests are secure secure_protocol = "dingtalk" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dingtalk/" # DingTalk API notify_url = "https://oapi.dingtalk.com/robot/send?access_token={token}" # Do not set title_maxlen as it is set in a property value below # since the length varies depending if we are doing a markdown # based message or a text based one. # title_maxlen = see below @propery defined # Define object templates templates = ( "{schema}://{token}/", "{schema}://{token}/{targets}/", "{schema}://{secret}@{token}/", "{schema}://{secret}@{token}/{targets}/", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "token": { "name": _("Token"), "type": "string", "private": True, "required": True, "regex": (r"^[a-z0-9]+$", "i"), }, "secret": { "name": _("Secret"), "type": "string", "private": True, "regex": (r"^[a-z0-9]+$", "i"), }, "target_phone_no": { "name": _("Target Phone No"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "to": { "alias_of": "targets", }, "token": { "alias_of": "token", }, "secret": { "alias_of": "secret", }, }, ) def __init__(self, token, targets=None, secret=None, **kwargs): """Initialize DingTalk Object.""" super().__init__(**kwargs) # Secret Key (associated with project) self.token = validate_regex( token, *self.template_tokens["token"]["regex"] ) if not self.token: msg = f"An invalid DingTalk API Token ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) self.secret = None if secret: self.secret = validate_regex( secret, *self.template_tokens["secret"]["regex"] ) if not self.secret: msg = f"An invalid DingTalk Secret ({token}) was specified." self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = [] for target in parse_list(targets): # Validate targets and drop bad ones: result = IS_PHONE_NO.match(target) if result: # Further check our phone # for it's digit count result = "".join(re.findall(r"\d+", result.group("phone"))) if len(result) < 11 or len(result) > 14: self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) continue # store valid phone number self.targets.append(result) continue self.logger.warning( f"Dropped invalid phone # ({target}) specified.", ) return def get_signature(self): """Calculates time-based signature so that we can send arbitrary messages.""" timestamp = str(round(time.time() * 1000)) secret_enc = self.secret.encode("utf-8") str_to_sign_enc = f"{timestamp}\n{self.secret}".encode() hmac_code = hmac.new( secret_enc, str_to_sign_enc, digestmod=hashlib.sha256 ).digest() signature = NotifyDingTalk.quote(base64.b64encode(hmac_code), safe="") return timestamp, signature def send(self, body, title="", notify_type=NotifyType.INFO, **kwargs): """Perform DingTalk Notification.""" payload = { "msgtype": "text", "at": { "atMobiles": self.targets, "isAtAll": False, }, } if self.notify_format == NotifyFormat.MARKDOWN: payload["markdown"] = { "title": title, "text": body, } else: payload["text"] = { "content": body, } # Our Notification URL notify_url = self.notify_url.format(token=self.token) params = None if self.secret: timestamp, signature = self.get_signature() params = { "timestamp": timestamp, "sign": signature, } # Prepare our headers headers = { "User-Agent": self.app_id, "Content-Type": "application/json", } # Some Debug Logging self.logger.debug( "DingTalk URL:" f" {notify_url} (cert_verify={self.verify_certificate})" ) self.logger.debug(f"DingTalk Payload: {payload}") # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url, data=dumps(payload), headers=headers, params=params, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code != requests.codes.ok: # We had a problem status_str = NotifyDingTalk.http_response_code_lookup( r.status_code ) self.logger.warning( "Failed to send DingTalk notification: " "{}{}error={}.".format( status_str, ", " if status_str else "", r.status_code ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) return False else: self.logger.info("Sent DingTalk notification.") except requests.RequestException as e: self.logger.warning( "A Connection error occured sending DingTalk notification." ) self.logger.debug(f"Socket Exception: {e!s}") return False return True @property def title_maxlen(self): """The title isn't used when not in markdown mode.""" return ( NotifyBase.title_maxlen if self.notify_format == NotifyFormat.MARKDOWN else 0 ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any arguments set args = { "format": self.notify_format, "overflow": self.overflow_mode, "verify": "yes" if self.verify_certificate else "no", } return "{schema}://{secret}{token}/{targets}/?{args}".format( schema=self.secure_protocol, secret=( "" if not self.secret else "{}@".format( self.pprint( self.secret, privacy, mode=PrivacyMode.Secret, safe="" ) ) ), token=self.pprint(self.token, privacy, safe=""), targets="/".join( [NotifyDingTalk.quote(x, safe="") for x in self.targets] ), args=NotifyDingTalk.urlencode(args), ) @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another simliar one. Targets or end points should never be identified here. """ return (self.secure_protocol, self.secret, self.token) def __len__(self): """Returns the number of targets associated with this notification.""" targets = len(self.targets) return targets if targets > 0 else 1 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to substantiate this object.""" results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results results["token"] = NotifyDingTalk.unquote(results["host"]) # if a user has been defined, use it's value as the secret if results.get("user"): results["secret"] = results.get("user") # Get our entries; split_path() looks after unquoting content for us # by default results["targets"] = NotifyDingTalk.split_path(results["fullpath"]) # Support the use of the `token` keyword argument if "token" in results["qsd"] and len(results["qsd"]["token"]): results["token"] = NotifyDingTalk.unquote(results["qsd"]["token"]) # Support the use of the `secret` keyword argument if "secret" in results["qsd"] and len(results["qsd"]["secret"]): results["secret"] = NotifyDingTalk.unquote( results["qsd"]["secret"] ) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"] += NotifyDingTalk.parse_list( results["qsd"]["to"] ) return results ================================================ FILE: apprise/plugins/discord.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # For this to work correctly you need to create a webhook. To do this just # click on the little gear icon next to the channel you're part of. From # here you'll be able to access the Webhooks menu and create a new one. # # When you've completed, you'll get a URL that looks a little like this: # https://discord.com/api/webhooks/417429632418316298/\ # JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js # # Simplified, it looks like this: # https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN # # This plugin will simply work using the url of: # discord://WEBHOOK_ID/WEBHOOK_TOKEN # # API Documentation on Webhooks: # - https://discord.com/developers/docs/resources/webhook # from __future__ import annotations from datetime import datetime, timedelta, timezone from itertools import chain from json import dumps import re from typing import Any import requests from ..attachment.base import AttachBase from ..common import NotifyFormat, NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..utils.parse import parse_bool, parse_list, validate_regex from .base import NotifyBase # Used to detect user/role IDs and @here/@everyone tokens. USER_ROLE_DETECTION_RE = re.compile( r"\s*(?:&?)(?P[0-9]+)>?|@(?P[a-z0-9]+))", re.I ) class NotifyDiscord(NotifyBase): """A wrapper to Discord Notifications.""" # The default descriptive name associated with the Notification service_name = "Discord" # The services URL service_url = "https://discord.com/" # The default secure protocol secure_protocol = "discord" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/discord/" # Discord Webhook notify_url = "https://discord.com/api/webhooks" # Support attachments attachment_support = True # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # Discord is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: # X-RateLimit-Reset: The epoc time (in seconds) we can expect our # rate-limit to be reset. # X-RateLimit-Remaining: an integer identifying how many requests we're # still allow to make. request_rate_per_sec = 0 # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) # The maximum allowable characters allowed in the body per message body_maxlen = 2000 # The 2000 characters above defined by the body_maxlen include that of the # title. Setting this to True ensures overflow options behave properly overflow_amalgamate_title = True # Discord has a limit of the number of fields you can include in an # embeds message. This value allows the discord message to safely # break into multiple messages to handle these cases. discord_max_fields = 10 # Define object templates templates = ( "{schema}://{webhook_id}/{webhook_token}", "{schema}://{botname}@{webhook_id}/{webhook_token}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "botname": { "name": _("Bot Name"), "type": "string", "map_to": "user", }, "webhook_id": { "name": _("Webhook ID"), "type": "string", "private": True, "required": True, }, "webhook_token": { "name": _("Webhook Token"), "type": "string", "private": True, "required": True, }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "tts": { "name": _("Text To Speech"), "type": "bool", "default": False, }, "avatar": { "name": _("Avatar Image"), "type": "bool", "default": True, }, "avatar_url": { "name": _("Avatar URL"), "type": "string", }, "href": { "name": _("URL"), "type": "string", }, "url": { "alias_of": "href", }, # Send a message to the specified thread within a webhook's # channel. The thread will automatically be unarchived. "thread": { "name": _("Thread ID"), "type": "string", }, "footer": { "name": _("Display Footer"), "type": "bool", "default": False, }, "footer_logo": { "name": _("Footer Logo"), "type": "bool", "default": True, }, "fields": { "name": _("Use Fields"), "type": "bool", "default": True, }, "flags": { "name": _("Discord Flags"), "type": "int", "min": 0, }, "image": { "name": _("Include Image"), "type": "bool", "default": False, "map_to": "include_image", }, # Explicit ping targets. Examples: # - ping=12345,67890 # - ping=<@12345>,<@&67890>,@here "ping": { "name": _("Ping Users/Roles"), "type": "list:string", }, }, ) def __init__( self, webhook_id: str, webhook_token: str, tts: bool = False, avatar: bool = True, footer: bool = False, footer_logo: bool = True, include_image: bool = False, fields: bool = True, avatar_url: str | None = None, href: str | None = None, thread: str | None = None, flags: int | None = None, ping: list[str] | None = None, **kwargs: Any, ) -> None: """Initialize Discord Object.""" super().__init__(**kwargs) # Webhook ID (associated with project) self.webhook_id = validate_regex(webhook_id) if not self.webhook_id: msg = ( f"An invalid Discord Webhook ID ({webhook_id}) was " "specified.") self.logger.warning(msg) raise TypeError(msg) # Webhook Token (associated with project) self.webhook_token = validate_regex(webhook_token) if not self.webhook_token: msg = ( "An invalid Discord Webhook Token " f"({webhook_token}) was specified." ) self.logger.warning(msg) raise TypeError(msg) # Text To Speech self.tts = tts # Over-ride Avatar Icon self.avatar = avatar # Place a footer self.footer = footer # include a footer_logo in footer self.footer_logo = footer_logo # Place a thumbnail image inline with the message body self.include_image = include_image # Use Fields self.fields = fields # Specified Thread ID self.thread_id = thread # Avatar URL # This allows a user to provide an over-ride to the otherwise # dynamically generated avatar url images self.avatar_url = avatar_url # A URL to have the title link to self.href = href # A URL to have the title link to if flags: try: self.flags = int(flags) if self.flags < NotifyDiscord.template_args["flags"]["min"]: raise ValueError() except (TypeError, ValueError): msg = ( f"An invalid Discord flags setting ({flags}) was " "specified.") self.logger.warning(msg) raise TypeError(msg) from None else: self.flags = None # Ping targets (tokens from URL, already split by parse_list) self.ping: list[str] = parse_list(ping) self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1.0 self.ratelimit_remaining = 1.0 return def send( self, body: str, title: str = "", notify_type: NotifyType = NotifyType.INFO, attach: list[AttachBase] | None = None, **kwargs: Any, ) -> bool: """Perform Discord Notification.""" payload: dict[str, Any] = { "tts": self.tts, # If Text-To-Speech is set to True, then we do not want to wait # for the whole message before continuing. Otherwise, we wait "wait": self.tts is False, } if self.flags: # Set our flag if defined: payload["flags"] = self.flags # Acquire image_url image_url = self.image_url(notify_type) if self.avatar and (image_url or self.avatar_url): payload["avatar_url"] = ( self.avatar_url if self.avatar_url else image_url ) if self.user: # Optionally override the default username of the webhook payload["username"] = self.user # Associate our thread_id with our message params = {"thread_id": self.thread_id} if self.thread_id else None # Ping handling rules: # - If ping= is set, it is an additive if in MARKDOWN mode otherwise # it is explicit for TEXT/HTML formats. # - Otherwise, ping detection only happens in MARKDOWN mode if self.notify_format == NotifyFormat.MARKDOWN: if self.ping: payload.update(self.ping_payload(body, " ".join(self.ping))) else: payload.update(self.ping_payload(body)) # TEXT/HTML: no body parsing, ping= is exclusive elif self.ping: payload.update(self.ping_payload(" ".join(self.ping))) if body: # Track extra embed fields (if used) fields: list[dict[str, str]] = [] if self.notify_format == NotifyFormat.MARKDOWN: # Use embeds for payload payload["embeds"] = [{ "author": { "name": self.app_id, "url": self.app_url, }, "title": title, "description": body, # Our color associated with our notification "color": self.color(notify_type, int), }] if self.href: payload["embeds"][0]["url"] = self.href if self.footer: # Acquire logo URL logo_url = self.image_url(notify_type, logo=True) # Set Footer text to our app description payload["embeds"][0]["footer"] = { "text": self.app_desc, } if self.footer_logo and logo_url: payload["embeds"][0]["footer"]["icon_url"] = logo_url if self.include_image and image_url: payload["embeds"][0]["thumbnail"] = { "url": image_url, "height": 256, "width": 256, } if self.fields: # Break titles out so that we can sort them in embeds description, fields = self.extract_markdown_sections(body) # Swap first entry for description payload["embeds"][0]["description"] = description if fields: # Apply our additional parsing for a better # presentation payload["embeds"][0]["fields"] = fields[ : self.discord_max_fields ] fields = fields[self.discord_max_fields :] else: # TEXT or HTML: # - No ping detection unless ping= was provided. # - If ping= was provided, ping_payload() already generated # payload["content"] starting with "👉 ...", and we append # it. payload["content"] = ( body if not title else f"{title}\r\n{body}" ) + payload.get("content", "") if not self._send(payload, params=params): # We failed to post our message return False # Send remaining fields (if any) if fields: payload["embeds"][0]["description"] = "" for i in range(0, len(fields), self.discord_max_fields): payload["embeds"][0]["fields"] = fields[ i : i + self.discord_max_fields ] if not self._send(payload): # We failed to post our message return False if attach and self.attachment_support: # Update our payload; the idea is to preserve it's other detected # and assigned values for re-use here too payload.update({ # Text-To-Speech "tts": False, # Wait until the upload has posted itself before continuing "wait": True, }) # # Remove our text/title based content for attachment use # payload.pop("embeds", None) payload.pop("content", None) payload.pop("allow_mentions", None) # # Send our attachments # for attachment in attach: self.logger.info( f"Posting Discord Attachment {attachment.name}" ) if not self._send(payload, params=params, attach=attachment): # We failed to post our message return False # Otherwise return return True def _send( self, payload: dict[str, Any], attach: AttachBase | None = None, params: dict[str, str] | None = None, rate_limit: int = 1, **kwargs: Any, ) -> bool: """Wrapper to the requests (post) object.""" # Our headers headers = { "User-Agent": self.app_id, } # Construct Notify URL notify_url = ( f"{self.notify_url}/{self.webhook_id}/{self.webhook_token}" ) self.logger.debug( "Discord POST URL:" f" {notify_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug(f"Discord Payload: {payload!s}") wait: float | None = None if self.ratelimit_remaining <= 0.0: # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the # Discord server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds wait = abs( ( self.ratelimit_reset - now + self.clock_skew ).total_seconds() ) # Always call throttle before any remote server i/o is made; self.throttle(wait=wait) # Perform some simple error checking if isinstance(attach, AttachBase): if not attach: # We could not access the attachment self.logger.error( f"Could not access attachment {attach.url(privacy=True)}." ) return False self.logger.debug( f"Posting Discord attachment {attach.url(privacy=True)}" ) # Our attachment path (if specified) files = None try: # Open our attachment path if required: if attach: files = { "file": ( attach.name, # file handle is safely closed in `finally`; inline # open is intentional open(attach.path, "rb"), # noqa: SIM115 ) } else: headers["Content-Type"] = "application/json; charset=utf-8" r = requests.post( notify_url, params=params, data=payload if files else dumps(payload), headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, ) # Handle rate limiting (if specified) try: # Store our rate limiting (if provided) self.ratelimit_remaining = float( r.headers.get("X-RateLimit-Remaining") ) self.ratelimit_reset = datetime.fromtimestamp( int(r.headers.get("X-RateLimit-Reset")), timezone.utc ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this # information gracefully accept this state and move on pass if r.status_code not in ( requests.codes.ok, requests.codes.no_content, ): # We had a problem status_str = NotifyBase.http_response_code_lookup( r.status_code ) if ( r.status_code == requests.codes.too_many_requests and rate_limit > 0 ): # handle rate limiting self.logger.warning( "Discord rate limiting in effect; " "blocking for %.2f second(s)", self.ratelimit_remaining, ) # Try one more time before failing return self._send( payload=payload, attach=attach, params=params, rate_limit=rate_limit - 1, **kwargs, ) self.logger.warning( "Failed to send {}to Discord notification: " "{}{}error={}.".format( attach.name if attach else "", status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) # Return; we're done return False else: self.logger.info( "Sent Discord {}.".format( "attachment" if attach else "notification" ) ) except requests.RequestException as e: self.logger.warning( "A Connection error occurred posting {}to Discord.".format( attach.name if attach else "" ) ) self.logger.debug(f"Socket Exception: {e!s}") return False except OSError as e: self.logger.warning( "An I/O error occurred while reading {}.".format( attach.name if attach else "attachment" ) ) self.logger.debug(f"I/O Exception: {e!s}") return False finally: # Close our file (if it's open) stored in the second element # of our files tuple (index 1) if files: files["file"][1].close() return True def url(self, privacy: bool = False, *args: Any, **kwargs: Any) -> str: """Returns the URL built dynamically based on specified arguments.""" params: dict[str, str] = { "tts": "yes" if self.tts else "no", "avatar": "yes" if self.avatar else "no", "footer": "yes" if self.footer else "no", "footer_logo": "yes" if self.footer_logo else "no", "image": "yes" if self.include_image else "no", "fields": "yes" if self.fields else "no", } if self.avatar_url: params["avatar_url"] = self.avatar_url if self.flags: params["flags"] = str(self.flags) if self.href: params["href"] = self.href if self.thread_id: params["thread"] = self.thread_id if self.ping: # Let Apprise urlencode handle list formatting params["ping"] = ",".join(self.ping) # Ensure our botname is set botname = f"{self.user}@" if self.user else "" # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) return ( "{schema}://{bname}{webhook_id}/{webhook_token}/?{params}".format( schema=self.secure_protocol, bname=botname, webhook_id=self.pprint(self.webhook_id, privacy, safe=""), webhook_token=self.pprint( self.webhook_token, privacy, safe=""), params=NotifyDiscord.urlencode(params), ) ) @property def url_identifier(self) -> tuple[str, str, str]: """Returns all of the identifiers that make this URL unique.""" return (self.secure_protocol, self.webhook_id, self.webhook_token) @staticmethod def parse_url(url: str) -> dict[str, Any] | None: """Parses the URL and returns arguments for instantiating this object. Syntax: discord://webhook_id/webhook_token """ results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Store our webhook ID webhook_id = NotifyDiscord.unquote(results["host"]) # Now fetch our tokens try: webhook_token = NotifyDiscord.split_path(results["fullpath"])[0] except IndexError: # Force some bad values that will get caught # in parsing later webhook_token = None results["webhook_id"] = webhook_id results["webhook_token"] = webhook_token # Text To Speech results["tts"] = parse_bool(results["qsd"].get("tts", False)) # Use sections # effectively detect multiple fields and break them off # into sections results["fields"] = parse_bool(results["qsd"].get("fields", True)) # Use Footer results["footer"] = parse_bool(results["qsd"].get("footer", False)) # Use Footer Logo results["footer_logo"] = parse_bool( results["qsd"].get("footer_logo", True) ) # Update Avatar Icon results["avatar"] = parse_bool(results["qsd"].get("avatar", True)) # Boolean to include an image or not results["include_image"] = parse_bool( results["qsd"].get( "image", NotifyDiscord.template_args["image"]["default"] ) ) if "botname" in results["qsd"]: # Alias to User results["user"] = NotifyDiscord.unquote(results["qsd"]["botname"]) if "flags" in results["qsd"]: # Alias to User results["flags"] = NotifyDiscord.unquote(results["qsd"]["flags"]) # Extract avatar url if it was specified if "avatar_url" in results["qsd"]: results["avatar_url"] = NotifyDiscord.unquote( results["qsd"]["avatar_url"] ) # Extract url if it was specified if "href" in results["qsd"]: results["href"] = NotifyDiscord.unquote(results["qsd"]["href"]) elif "url" in results["qsd"]: results["href"] = NotifyDiscord.unquote(results["qsd"]["url"]) # Markdown is implied results["format"] = NotifyFormat.MARKDOWN # Extract thread id if it was specified if "thread" in results["qsd"]: results["thread"] = NotifyDiscord.unquote(results["qsd"]["thread"]) # Markdown is implied results["format"] = NotifyFormat.MARKDOWN # Extract ping targets, comma/space separated if "ping" in results["qsd"]: results["ping"] = NotifyDiscord.unquote(results["qsd"]["ping"]) return results @staticmethod def parse_native_url(url: str) -> dict[str, Any] | None: """ Support https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN Support Legacy URL as well: https://discordapp.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN """ result = re.match( r"^https?://discord(app)?\.com/api/webhooks/" r"(?P[0-9]+)/" r"(?P[A-Z0-9_-]+)/?" r"(?P\?.+)?$", url, re.I, ) if result: return NotifyDiscord.parse_url( "{schema}://{webhook_id}/{webhook_token}/{params}".format( schema=NotifyDiscord.secure_protocol, webhook_id=result.group("webhook_id"), webhook_token=result.group("webhook_token"), params=( "" if not result.group("params") else result.group("params") ), ) ) return None def ping_payload(self, *args: str) -> dict[str, Any]: """ Takes one or more strings and applies the payload associated with pinging the users detected within. This returns a dict that may contain: - allow_mentions - content (starting with "👉 " and containing mention tokens) """ payload: dict[str, Any] = {} roles: set[str] = set() users: set[str] = set() parse: set[str] = set() for arg in args: # parse for user id's <@123> and role IDs <@&456> results = USER_ROLE_DETECTION_RE.findall(arg) if not results: continue for is_role, no, value in results: if value: parse.add(value) elif is_role: roles.add(no) else: # is_user users.add(no) if not (roles or users or parse): # Nothing to add return payload payload["allow_mentions"] = { "parse": list(parse), "users": list(users), "roles": list(roles), } payload["content"] = "👉 " + " ".join( chain( [f"@{value}" for value in parse], [f"<@&{value}>" for value in roles], [f"<@{value}>" for value in users], ) ) return payload @staticmethod def extract_markdown_sections( markdown: str) -> tuple[str, list[dict[str, str]]]: """Extract headers and their corresponding sections into embed fields.""" # Search for any header information found without it's own section # identifier match = re.match( r"^\s*(?P[^\s#]+.*?)(?=\s*$|[\r\n]+\s*#)", markdown, flags=re.S, ) description = match.group("desc").strip() if match else "" if description: # Strip description from our string since it has been handled # now. markdown = re.sub(re.escape(description), "", markdown, count=1) regex = re.compile( r"\s*#[# \t\v]*(?P[^\n]+)(\n|\s*$)" r"\s*((?P[^#].+?)(?=\s*$|[\r\n]+\s*#))?", flags=re.S, ) common = regex.finditer(markdown) fields: list[dict[str, str]] = [] for el in common: d = el.groupdict() fields.append({ "name": d.get("name", "").strip("#`* \r\n\t\v"), "value": "```{}\n{}```".format( "md" if d.get("value") else "", (d.get("value").strip() + "\n" if d.get("value") else ""), ), }) return description, fields ================================================ FILE: apprise/plugins/dot.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # # API: https://dot.mindreset.tech/docs/service/studio/api/text_api # https://dot.mindreset.tech/docs/service/studio/api/image_api # # Text API Fields: # - refreshNow (bool, optional, default true): controls display timing. # - deviceId (string, required): unique device serial. # - title (string, optional): title text shown on screen. # - message (string, optional): body text shown on screen. # - signature (string, optional): footer/signature text. # - icon (string, optional): base64 PNG icon (40px x 40px). # - link (string, optional): tap-to-interact target URL. # # Image API Fields: # - refreshNow (bool, optional, default true): controls display timing. # - deviceId (string, required): unique device serial. # - image (string, required): base64 PNG image (296px x 152px). # - link (string, optional): tap-to-interact target URL. # - border (number, optional, default 0): 0=white, 1=black frame. # - ditherType (string, optional, default DIFFUSION): dithering mode. # - ditherKernel (string, optional, default FLOYD_STEINBERG): # dithering kernel. from contextlib import suppress import json import logging import requests from ..common import NotifyImageSize, NotifyType from ..locale import gettext_lazy as _ from ..url import PrivacyMode from ..utils.parse import parse_bool from ..utils.sanitize import sanitize_payload from .base import NotifyBase # Supported Dither Types DOT_DITHER_TYPES = ( "DIFFUSION", "ORDERED", "NONE", ) # Supported Dither Kernels DOT_DITHER_KERNELS = ( "THRESHOLD", "ATKINSON", "BURKES", "FLOYD_STEINBERG", "SIERRA2", "STUCKI", "JARVIS_JUDICE_NINKE", "DIFFUSION_ROW", "DIFFUSION_COLUMN", "DIFFUSION_2D", ) class NotifyDot(NotifyBase): """A wrapper for Dot. Notifications.""" # The default descriptive name associated with the Notification service_name = "Dot." # Alias: devices marketed as "Quote/0" remain discoverable. # The services URL service_url = "https://dot.mindreset.tech" # All notification requests are secure secure_protocol = "dot" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/dot/" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 # Support Attachments attachment_support = True # Supported API modes SUPPORTED_MODES = ("text", "image") DEFAULT_MODE = "text" # Define object templates templates = ("{schema}://{apikey}@{device_id}/{mode}/",) # Define our template arguments template_tokens = dict( NotifyBase.template_tokens, **{ "apikey": { "name": _("API Key"), "type": "string", "required": True, "private": True, }, "device_id": { "name": _("Device Serial Number"), "type": "string", "required": True, "map_to": "device_id", }, "mode": { "name": _("API Mode"), "type": "choice:string", "values": SUPPORTED_MODES, "default": DEFAULT_MODE, "required": True, "map_to": "mode", }, }, ) # Define our template arguments template_args = dict( NotifyBase.template_args, **{ "refresh": { "name": _("Refresh Now"), "type": "bool", "default": True, "map_to": "refresh_now", }, "signature": { "name": _("Text Signature"), "type": "string", }, "icon": { "name": _("Icon Base64 (Text API)"), "type": "string", }, "image": { "name": _("Image Base64 (Image API)"), "type": "string", "map_to": "image_data", }, "link": { "name": _("Link"), "type": "string", }, "border": { "name": _("Border"), "type": "int", "min": 0, "max": 1, "default": 0, }, "dither_type": { "name": _("Dither Type"), "type": "choice:string", "values": DOT_DITHER_TYPES, "default": "DIFFUSION", }, "dither_kernel": { "name": _("Dither Kernel"), "type": "choice:string", "values": DOT_DITHER_KERNELS, "default": "FLOYD_STEINBERG", }, }, ) # Note: # - icon (Text API): base64 PNG icon (40px x 40px) in lower-left corner. # Can be provided via `icon` parameter or first attachment. # - image (Image API): base64 PNG image (296px x 152px) supplied via # configuration `image` parameter or first attachment. # - Only the first attachment is used; multiple attachments trigger a # warning. def __init__( self, apikey=None, device_id=None, mode=DEFAULT_MODE, refresh_now=True, signature=None, icon=None, link=None, border=None, dither_type=None, dither_kernel=None, image_data=None, **kwargs, ): """Initialize Notify Dot Object.""" super().__init__(**kwargs) # API Key (from user) self.apikey = apikey # Device ID tracks the Dot hardware serial. self.device_id = device_id # Refresh Now flag: True shows content immediately (default). self.refresh_now = parse_bool(refresh_now, default=True) # API mode ("text" or "image") self.mode = ( mode.lower() if isinstance(mode, str) and mode.lower() in self.SUPPORTED_MODES else self.DEFAULT_MODE ) if ( not isinstance(mode, str) or mode.lower() not in self.SUPPORTED_MODES ): self.logger.warning( "Unsupported Dot mode (%s) specified; defaulting to '%s'.", mode, self.mode, ) # Signature text used by the Text API footer. self.signature = signature if isinstance(signature, str) else None # Icon for the Text API (base64 PNG 40x40, lower-left corner). # Note: distinct from the Image API "image" field. self.icon = icon if isinstance(icon, str) else None # Image payload for the Image API (base64 PNG 296x152). self.image_data = image_data if isinstance(image_data, str) else None if self.mode == "text" and self.image_data: self.logger.warning( "Image data provided in text mode; ignoring configurable" " image payload." ) self.image_data = None # Link for tap-to-interact navigation. self.link = link if isinstance(link, str) else None # Border for the Image API self.border = border # Dither type for Image API self.dither_type = dither_type # Dither kernel for the Image API self.dither_kernel = dither_kernel # Text API endpoint self.text_api_url = "https://dot.mindreset.tech/api/open/text" # Image API endpoint self.image_api_url = "https://dot.mindreset.tech/api/open/image" return def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): """Perform Dot Notification.""" if not self.apikey: self.logger.warning("No API key was specified") return False if not self.device_id: self.logger.warning("No device ID was specified") return False # Prepare our headers headers = { "Authorization": f"Bearer {self.apikey}", "Content-Type": "application/json", "User-Agent": self.app_id, } if self.mode == "image": if title or body: self.logger.warning( "Title and body are not supported in image mode " "and will be ignored." ) image_data = ( self.image_data if isinstance(self.image_data, str) else None ) # Use first attachment as image if no image_data provided # attachment.base64() returns base64-encoded string for API if not image_data and attach and self.attachment_support: if len(attach) > 1: self.logger.warning( "Multiple attachments provided; only the first " "one will be used as image." ) try: attachment = attach[0] if attachment: # Convert attachment to base64-encoded string image_data = attachment.base64() except Exception as e: self.logger.warning(f"Failed to process attachment: {e!s}") if not image_data: self.logger.warning( "Image API mode selected but no image data was provided." ) return False # Use Image API # Image API payload: # refreshNow: display timing control. # deviceId: Dot device serial (required). # image: base64 PNG 296x152 (required). # link: optional tap target. # border: optional frame color. # ditherType: optional dithering mode. # ditherKernel: optional dithering kernel. payload = { "refreshNow": self.refresh_now, "deviceId": self.device_id, "image": image_data, # Image payload shown on screen } if self.link: payload["link"] = self.link if self.border is not None: payload["border"] = self.border if self.dither_type is not None: payload["ditherType"] = self.dither_type if self.dither_kernel is not None: payload["ditherKernel"] = self.dither_kernel api_url = self.image_api_url else: # Use Text API # Text API payload: # refreshNow: display timing control. # deviceId: Dot device serial (required). # title: optional title on screen. # message: optional body on screen. # signature: optional footer text. # icon: optional base64 PNG icon (40x40). # link: optional tap target. payload = { "refreshNow": self.refresh_now, "deviceId": self.device_id, } if title: payload["title"] = title if body: payload["message"] = body if self.signature: payload["signature"] = ( self.signature ) # Footer/signature displayed on screen # Use first attachment as icon if no icon provided # attachment.base64() returns base64-encoded string for API icon_data = self.icon if not icon_data and attach and self.attachment_support: if len(attach) > 1: self.logger.warning( "Multiple attachments provided; only the first " "one will be used as icon." ) try: attachment = attach[0] if attachment: # Convert attachment to base64-encoded string icon_data = attachment.base64() except Exception as e: self.logger.warning(f"Failed to process attachment: {e!s}") if icon_data: # Text API icon payload payload["icon"] = icon_data if self.link: payload["link"] = self.link api_url = self.text_api_url # Some Debug Logging if self.logger.isEnabledFor(logging.DEBUG): # Due to attachments; output can be quite heavy and io intensive # To accommodate this, we only show our debug payload information # if required. self.logger.debug( "Dot POST URL:" f" {api_url} (cert_verify={self.verify_certificate!r})" ) self.logger.debug("Dot Payload %s", sanitize_payload(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( api_url, data=json.dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code == requests.codes.ok: self.logger.info(f"Sent Dot notification to {self.device_id}.") return True # We had a problem status_str = NotifyDot.http_response_code_lookup(r.status_code) self.logger.warning( "Failed to send Dot notification to {}: " "{}{}error={}.".format( self.device_id, status_str, ", " if status_str else "", r.status_code, ) ) self.logger.debug( "Response Details:\r\n%r", (r.content or b"")[:2000]) return False except requests.RequestException as e: self.logger.warning( "A Connection error occurred sending Dot " f"notification to {self.device_id}." ) self.logger.debug(f"Socket Exception: {e!s}") return False @property def url_identifier(self): """Returns all of the identifiers that make this URL unique from another similar one. """ return ( self.secure_protocol, self.apikey, self.device_id, self.mode, ) def url(self, privacy=False, *args, **kwargs): """Returns the URL built dynamically based on specified arguments.""" # Define any URL parameters params = { "refresh": "yes" if self.refresh_now else "no", } if self.mode == "text": if self.signature: params["signature"] = self.signature if self.icon: params["icon"] = self.icon if self.link: params["link"] = self.link else: # image mode if self.image_data: params["image"] = self.image_data if self.link: params["link"] = self.link if self.border is not None: params["border"] = str(self.border) if self.dither_type and self.dither_type != "DIFFUSION": params["dither_type"] = self.dither_type if self.dither_kernel and self.dither_kernel != "FLOYD_STEINBERG": params["dither_kernel"] = self.dither_kernel # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) mode_segment = f"/{self.mode}/" return "{schema}://{apikey}@{device_id}{mode}?{params}".format( schema=self.secure_protocol, apikey=self.pprint( self.apikey, privacy, mode=PrivacyMode.Secret, safe="" ), device_id=NotifyDot.quote(self.device_id, safe=""), mode=mode_segment, params=NotifyDot.urlencode(params), ) def __len__(self): """Returns the number of targets associated with this notification.""" return 1 if self.device_id else 0 @staticmethod def parse_url(url): """Parses the URL and returns enough arguments that can allow us to re- instantiate this object.""" results = NotifyBase.parse_url(url) if not results: # We're done early as we couldn't load the results return results # Determine API mode from path (default to text) mode = NotifyDot.DEFAULT_MODE path_tokens = NotifyDot.split_path(results.get("fullpath")) if path_tokens: candidate = path_tokens.pop(0).lower() if candidate in NotifyDot.SUPPORTED_MODES: mode = candidate else: NotifyDot.logger.warning( "Unsupported Dot mode (%s) detected; defaulting to '%s'.", candidate, NotifyDot.DEFAULT_MODE, ) results["mode"] = mode remaining_path = "/".join(path_tokens) results["fullpath"] = "/" + remaining_path if remaining_path else "/" results["path"] = remaining_path # Extract API key from user user = results.get("user") if user: results["apikey"] = NotifyDot.unquote(user) # Extract device ID from hostname host = results.get("host") if host: results["device_id"] = NotifyDot.unquote(host) # Refresh Now refresh_value = results["qsd"].get("refresh") if refresh_value: results["refresh_now"] = parse_bool(refresh_value.strip()) # Signature signature_value = results["qsd"].get("signature") if signature_value: results["signature"] = NotifyDot.unquote(signature_value.strip()) # Icon icon_value = results["qsd"].get("icon") if icon_value: results["icon"] = NotifyDot.unquote(icon_value.strip()) # Link link_value = results["qsd"].get("link") if link_value: results["link"] = NotifyDot.unquote(link_value.strip()) # Border border_value = results["qsd"].get("border") if border_value: with suppress(TypeError, ValueError): results["border"] = int(border_value.strip()) # Dither Type dither_type_value = results["qsd"].get("dither_type") if dither_type_value: results["dither_type"] = NotifyDot.unquote( dither_type_value.strip() ) # Dither Kernel dither_kernel_value = results["qsd"].get("dither_kernel") if dither_kernel_value: results["dither_kernel"] = NotifyDot.unquote( dither_kernel_value.strip() ) # Image (Image API) image_value = results["qsd"].get("image") if image_value: results["image_data"] = NotifyDot.unquote(image_value.strip()) return results ================================================ FILE: apprise/plugins/email/__init__.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from email import charset from .base import NotifyEmail from .common import ( SECURE_MODES, AppriseEmailException, EmailMessage, SecureMailMode, WebBaseLogin, ) from .templates import EMAIL_TEMPLATES # Globally Default encoding mode set to Quoted Printable. charset.add_charset("utf-8", charset.QP, charset.QP, "utf-8") __all__ = [ "EMAIL_TEMPLATES", "SECURE_MODES", "AppriseEmailException", "EmailMessage", "NotifyEmail", "SecureMailMode", "WebBaseLogin", ] ================================================ FILE: apprise/plugins/email/base.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from datetime import datetime from email.header import Header from email.mime.application import MIMEApplication from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import format_datetime, formataddr, make_msgid import re import smtplib from typing import Optional from ...common import NotifyFormat, NotifyType, PersistentStoreMode from ...conversion import convert_between from ...locale import gettext_lazy as _ from ...logger import logger from ...url import PrivacyMode from ...utils import pgp as _pgp from ...utils.parse import ( is_email, is_hostname, is_ipaddr, parse_bool, parse_emails, ) from ..base import NotifyBase from . import templates from .common import ( SECURE_MODES, AppriseEmailException, EmailMessage, SecureMailMode, WebBaseLogin, ) class NotifyEmail(NotifyBase): """ A wrapper to Email Notifications """ # The default descriptive name associated with the Notification service_name = "E-Mail" # The default simple (insecure) protocol protocol = "mailto" # The default secure protocol secure_protocol = "mailtos" # A URL that takes you to the setup/help of the specific protocol setup_url = "https://appriseit.com/services/email/" # Support attachments attachment_support = True # Our default is to no not use persistent storage beyond in-memory # reference; this allows us to auto-generate our config if needed storage_mode = PersistentStoreMode.AUTO # Default Notify Format notify_format = NotifyFormat.HTML # Default SMTP Timeout (in seconds) socket_connect_timeout = 15 # Define object templates templates = ( "{schema}://{host}", "{schema}://{host}/{targets}", "{schema}://{user}@{host}", "{schema}://{user}@{host}/{targets}", "{schema}://{user}@{host}:{port}", "{schema}://{user}@{host}/{targets}", "{schema}://{user}@{host}:{port}/{targets}", "{schema}://{user}:{password}@{host}", "{schema}://{user}:{password}@{host}/{targets}", "{schema}://{user}:{password}@{host}:{port}", "{schema}://{user}:{password}@{host}:{port}/{targets}", ) # Define our template tokens template_tokens = dict( NotifyBase.template_tokens, **{ "user": { "name": _("User Name"), "type": "string", }, "password": { "name": _("Password"), "type": "string", "private": True, }, "host": { "name": _("Domain"), "type": "string", "required": True, }, "port": { "name": _("Port"), "type": "int", "min": 1, "max": 65535, }, "target_email": { "name": _("Target Email"), "type": "string", "map_to": "targets", }, "targets": { "name": _("Targets"), "type": "list:string", }, }, ) template_args = dict( NotifyBase.template_args, **{ "from": { "name": _("From Email"), "type": "string", "map_to": "from_addr", }, "name": { "name": _("From Name"), "type": "string", "map_to": "from_addr", }, "smtp": { "name": _("SMTP Server"), "type": "string", "map_to": "smtp_host", }, "mode": { "name": _("Secure Mode"), "type": "choice:string", "values": SECURE_MODES, "default": SecureMailMode.STARTTLS, "map_to": "secure_mode", }, "reply": { "name": _("Reply To"), "type": "list:string", "map_to": "reply_to", }, "pgp": { "name": _("PGP Encryption"), "type": "bool", "map_to": "use_pgp", "default": False, }, "pgpkey": { "name": _("PGP Public Key Path"), "type": "string", "private": True, # By default persistent storage is referenced "default": "", "map_to": "pgp_key", }, "to": { "name": _("To Email"), "type": "string", "map_to": "targets", }, "cc": { "name": _("Carbon Copy"), "type": "list:string", }, "bcc": { "name": _("Blind Carbon Copy"), "type": "list:string", }, }, ) # Define any kwargs we're using template_kwargs = { "headers": { "name": _("Email Header"), "prefix": "+", }, } def __init__( self, smtp_host=None, from_addr=None, secure_mode=None, targets=None, cc=None, bcc=None, reply_to=None, headers=None, use_pgp=None, pgp_key=None, **kwargs, ): """ Initialize Email Object The smtp_host and secure_mode can be automatically detected depending on how the URL was built """ super().__init__(**kwargs) # Acquire Email 'To' self.targets = [] # Acquire Carbon Copies self.cc = set() # Acquire Blind Carbon Copies self.bcc = set() # Acquire Reply To self.reply_to = set() # For tracking our email -> name lookups self.names = {} self.headers = {} if headers: # Store our extra headers self.headers.update(headers) # Now we want to construct the To and From email # addresses from the URL provided self.from_addr = [False, ""] # Now detect the SMTP Server self.smtp_host = smtp_host if isinstance(smtp_host, str) else "" # Now detect secure mode if secure_mode: self.secure_mode = ( None if not isinstance(secure_mode, str) else secure_mode.lower() ) else: self.secure_mode = ( SecureMailMode.INSECURE if not self.secure else self.template_args["mode"]["default"] ) if self.secure_mode not in SECURE_MODES: msg = "The secure mode specified ({}) is invalid.".format( secure_mode ) self.logger.warning(msg) raise TypeError(msg) # Validate recipients (cc:) and drop bad ones: for recipient in parse_emails(cc): email = is_email(recipient) if email: self.cc.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Carbon Copy email ({}) specified.".format( recipient ), ) # Validate recipients (bcc:) and drop bad ones: for recipient in parse_emails(bcc): email = is_email(recipient) if email: self.bcc.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Blind Carbon Copy email " "({}) specified.".format(recipient), ) # Validate recipients (reply-to:) and drop bad ones: for recipient in parse_emails(reply_to): email = is_email(recipient) if email: self.reply_to.add(email["full_email"]) # Index our name (if one exists) self.names[email["full_email"]] = ( email["name"] if email["name"] else False ) continue self.logger.warning( "Dropped invalid Reply To email ({}) specified.".format( recipient ), ) # Apply any defaults based on certain known configurations self.apply_email_defaults(secure_mode=secure_mode, **kwargs) if self.user: if self.host: # Prepare the bases of our email self.from_addr = [ self.app_id, "{}@{}".format( re.split(r"[\s@]+", self.user)[0], self.host, ), ] else: result = is_email(self.user) if result: # Prepare the bases of our email and include domain self.host = result["domain"] self.from_addr = [self.app_id, self.user] if from_addr: result = is_email(from_addr) if result: self.from_addr = ( result["name"] if result["name"] else False, result["full_email"], ) else: # Only update the string but use the already detected info self.from_addr[0] = from_addr result = is_email(self.from_addr[1]) if not result: # Parse Source domain based on from_addr msg = "Invalid ~From~ email specified: {}".format( "{} <{}>".format(self.from_addr[0], self.from_addr[1]) if self.from_addr[0] else "{}".format(self.from_addr[1]) ) self.logger.warning(msg) raise TypeError(msg) # Store our lookup self.names[self.from_addr[1]] = self.from_addr[0] if targets: # Validate recipients (to:) and drop bad ones: for recipient in parse_emails(targets): result = is_email(recipient) if result: self.targets.append(( result["name"] if result["name"] else False, result["full_email"], )) continue self.logger.warning( "Dropped invalid To email ({}) specified.".format( recipient ), ) else: # If our target email list is empty we want to add ourselves to it self.targets.append((False, self.from_addr[1])) if not self.secure and self.secure_mode != SecureMailMode.INSECURE: # Enable Secure mode if not otherwise set self.secure = True if not self.port: # Assign our port based on our secure_mode if not otherwise # detected self.port = SECURE_MODES[self.secure_mode]["default_port"] # if there is still no smtp_host then we fall back to the hostname if not self.smtp_host: self.smtp_host = self.host # Prepare our Pretty Good Privacy Object self.pgp = _pgp.ApprisePGPController( path=self.store.path, pub_keyfile=pgp_key, email=self.from_addr[1], asset=self.asset, ) # We store so we can generate a URL later on self.pgp_key = pgp_key self.use_pgp = ( use_pgp if not None else self.template_args["pgp"]["default"] ) if self.use_pgp and not _pgp.PGP_SUPPORT: self.logger.warning( "PGP Support is not available on this installation; " "ask admin to install PGPy" ) return def apply_email_defaults(self, secure_mode=None, port=None, **kwargs): """ A function that prefills defaults based on the email it was provided. """ if self.smtp_host: # SMTP Server was explicitly specified, therefore it is assumed # the caller knows what he's doing and is intentionally # over-riding any smarts to be applied. We also can not apply # any default if there was no user specified. return # detect our email address using our user/host combo from_addr = ( "{}@{}".format( re.split(r"[\s@]+", self.user)[0], self.host, ) if self.user else self.host ) for i in range(len(templates.EMAIL_TEMPLATES)): # pragma: no branch self.logger.trace( "Scanning %s against %s", from_addr, templates.EMAIL_TEMPLATES[i][0]) match = templates.EMAIL_TEMPLATES[i][1].match(from_addr) if match: self.logger.info( f"Applying {templates.EMAIL_TEMPLATES[i][0]} Defaults") # the secure flag can not be altered if defined in the template self.secure = templates.EMAIL_TEMPLATES[i][2].get( "secure", self.secure ) # The SMTP Host check is already done above; if it was # specified we wouldn't even reach this part of the code. self.smtp_host = templates.EMAIL_TEMPLATES[i][2].get( "smtp_host", self.smtp_host ) # The following can be over-ridden if defined manually in the # Apprise URL. Otherwise they take on the template value if not port: self.port = templates.EMAIL_TEMPLATES[i][2].get( "port", self.port ) if not secure_mode: self.secure_mode = templates.EMAIL_TEMPLATES[i][2].get( "secure_mode", self.secure_mode ) # Adjust email login based on the defined usertype. If no entry # was specified, then we default to having them all set (which # basically implies that there are no restrictions and use use # whatever was specified) login_type = templates.EMAIL_TEMPLATES[i][2].get( "login_type", [] ) if login_type: # only apply additional logic to our user if a login_type # was specified. if is_email(self.user): if WebBaseLogin.EMAIL not in login_type: # Email specified but login type # not supported; switch it to user id self.user = match.group("id") else: # Enforce our host information self.host = self.user.split("@")[1] elif WebBaseLogin.USERID not in login_type: # user specified but login type # not supported; switch it to email self.user = "{}@{}".format(self.user, self.host) if ( "from_user" in templates.EMAIL_TEMPLATES[i][2] and not self.from_addr[1] ): # Update our from address if defined self.from_addr[1] = "{}@{}".format( templates.EMAIL_TEMPLATES[i][2]["from_user"], self.host ) break def send( self, body, title="", notify_type=NotifyType.INFO, attach=None, **kwargs, ): if not self.targets: # There is no one to email; we're done logger.warning("There are no Email recipients to notify") return False # error tracking (used for function return) has_error = False # bind the socket variable to the current namespace socket = None # Always call throttle before any remote server i/o is made self.throttle() try: self.logger.debug("Connecting to remote SMTP server...") socket_func = smtplib.SMTP if self.secure_mode == SecureMailMode.SSL: self.logger.debug("Securing connection with SSL...") socket_func = smtplib.SMTP_SSL socket = socket_func( self.smtp_host, self.port, None, timeout=self.socket_connect_timeout, ) if self.secure_mode == SecureMailMode.STARTTLS: # Handle Secure Connections self.logger.debug("Securing connection with STARTTLS...") socket.starttls() self.logger.trace("Login ID: {}".format(self.user)) if self.user and self.password: # Apply Login credentials self.logger.debug("Applying user credentials...") socket.login(self.user, self.password) # Prepare our headers headers = { "X-Application": self.app_id, } headers.update(self.headers) # Iterate over our email messages we can generate and then # send them off. for message in NotifyEmail.prepare_emails( subject=title, body=body, notify_format=self.notify_format, from_addr=self.from_addr, to=self.targets, cc=self.cc, bcc=self.bcc, reply_to=self.reply_to, smtp_host=self.smtp_host, attach=attach, headers=headers, names=self.names, pgp=self.pgp if self.use_pgp else None, tzinfo=self.tzinfo, ): try: socket.sendmail( self.from_addr[1], message.to_addrs, message.body ) self.logger.info("Sent Email to %s", message.recipient) except (OSError, smtplib.SMTPException, RuntimeError) as e: self.logger.warning( 'Sending email to "%s" failed.', message.recipient ) self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True except (OSError, smtplib.SMTPException, RuntimeError) as e: self.logger.warning( 'Connection error while submitting email to "%s"', self.smtp_host, ) self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True except AppriseEmailException as e: self.logger.debug(f"Socket Exception: {e}") # Mark as failure has_error = True finally: # Gracefully terminate the connection with the server if socket is not None: socket.quit() # Reduce our dictionary (eliminate expired keys if any) self.pgp.prune() return not has_error def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ # Define an URL parameters params = { "pgp": "yes" if self.use_pgp else "no", } # Store our public key back into your URL if self.pgp_key is not None: params["pgp_key"] = NotifyEmail.quote(self.pgp_key, safe=":\\/") # Append our headers into our parameters params.update({"+{}".format(k): v for k, v in self.headers.items()}) # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) from_addr = None if len(self.targets) == 1 and self.targets[0][1] != self.from_addr[1]: # A custom email was provided from_addr = self.from_addr[1] if self.smtp_host != self.host: # Apply our SMTP Host only if it differs from the provided hostname params["smtp"] = self.smtp_host if self.secure: # Mode is only required if we're dealing with a secure connection params["mode"] = self.secure_mode if self.from_addr[0] and self.from_addr[0] != self.app_id: # A custom name was provided params["from"] = ( self.from_addr[0] if not from_addr else formataddr( (self.from_addr[0], from_addr), charset="utf-8" ) ) elif from_addr: params["from"] = formataddr((False, from_addr), charset="utf-8") elif not self.user: params["from"] = formataddr( (False, self.from_addr[1]), charset="utf-8" ) if self.cc: # Handle our Carbon Copy Addresses params["cc"] = ",".join([ formataddr( (self.names.get(e, False), e), # Swap comma for it's escaped url code (if detected) since # we're using that as a delimiter charset="utf-8", ).replace(",", "%2C") for e in self.cc ]) if self.bcc: # Handle our Blind Carbon Copy Addresses params["bcc"] = ",".join([ formataddr( (self.names.get(e, False), e), # Swap comma for it's escaped url code (if detected) since # we're using that as a delimiter charset="utf-8", ).replace(",", "%2C") for e in self.bcc ]) if self.reply_to: # Handle our Reply-To Addresses params["reply"] = ",".join([ formataddr( (self.names.get(e, False), e), # Swap comma for its escaped url code (if detected) since # we're using that as a delimiter charset="utf-8", ).replace(",", "%2C") for e in self.reply_to ]) # pull email suffix from username (if present) user = None if not self.user else self.user.split("@")[0] # Determine Authentication auth = "" if self.user and self.password: auth = "{user}:{password}@".format( user=NotifyEmail.quote(user, safe=""), password=self.pprint( self.password, privacy, mode=PrivacyMode.Secret, safe="" ), ) elif user: # user url auth = "{user}@".format( user=NotifyEmail.quote(user, safe=""), ) # Default Port setup default_port = SECURE_MODES[self.secure_mode]["default_port"] # a simple boolean check as to whether we display our target emails # or not has_targets = not ( len(self.targets) == 1 and self.targets[0][1] == self.from_addr[1] ) return "{schema}://{auth}{hostname}{port}/{targets}?{params}".format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, # never encode hostname since we're expecting it to be a valid one hostname=self.host, port=( "" if self.port is None or self.port == default_port else ":{}".format(self.port) ), targets=( "" if not has_targets else "/".join([ NotifyEmail.quote( "{}{}".format( "" if not e[0] else "{}:".format(e[0]), e[1] ), safe="", ) for e in self.targets ]) ), params=NotifyEmail.urlencode(params), ) @property def url_identifier(self): """ Returns all of the identifiers that make this URL unique from another similar one. Targets or end points should never be identified here. """ return ( self.secure_protocol if self.secure else self.protocol, self.user, self.password, self.host, self.smtp_host, ( self.port if self.port else SECURE_MODES[self.secure_mode]["default_port"] ), ) def __len__(self): """ Returns the number of targets associated with this notification """ return len(self.targets) if self.targets else 1 @staticmethod def parse_url(url): """ Parses the URL and returns enough arguments that can allow us to re-instantiate this object. """ results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # Prepare our target lists results["targets"] = [] if is_ipaddr(results["host"]): # Silently move on and do not disrupt any configuration pass elif not is_hostname( results["host"], ipv4=False, ipv6=False, underscore=False ): if is_email(NotifyEmail.unquote(results["host"])): # Don't lose defined email addresses results["targets"].append(NotifyEmail.unquote(results["host"])) # Detect if we have a valid hostname or not; be sure to reset it's # value if invalid; we'll attempt to figure this out later on results["host"] = "" # Get PGP Flag results["use_pgp"] = parse_bool( results["qsd"].get( "pgp", NotifyEmail.template_args["pgp"]["default"] ) ) # Get PGP Public Key Override if "pgpkey" in results["qsd"] and results["qsd"]["pgpkey"]: results["pgp_key"] = NotifyEmail.unquote(results["qsd"]["pgpkey"]) # The From address is a must; either through the use of templates # from= entry and/or merging the user and hostname together, this # must be calculated or parse_url will fail. from_addr = "" # The server we connect to to send our mail to smtp_host = "" # Get our potential email targets; if none our found we'll just # add one to ourselves results["targets"] += NotifyEmail.split_path(results["fullpath"]) # Attempt to detect 'to' email address if "to" in results["qsd"] and len(results["qsd"]["to"]): results["targets"].append(results["qsd"]["to"]) # Attempt to detect 'from' email address if "from" in results["qsd"] and len(results["qsd"]["from"]): from_addr = NotifyEmail.unquote(results["qsd"]["from"]) if "name" in results["qsd"] and len(results["qsd"]["name"]): from_addr = formataddr( (NotifyEmail.unquote(results["qsd"]["name"]), from_addr), charset="utf-8", ) elif "name" in results["qsd"] and len(results["qsd"]["name"]): # Extract from name to associate with from address from_addr = NotifyEmail.unquote(results["qsd"]["name"]) # Store SMTP Host if specified if "smtp" in results["qsd"] and len(results["qsd"]["smtp"]): # Extract the smtp server smtp_host = NotifyEmail.unquote(results["qsd"]["smtp"]) if "mode" in results["qsd"] and len(results["qsd"]["mode"]): # Extract the secure mode to over-ride the default results["secure_mode"] = results["qsd"]["mode"].lower() # Handle Carbon Copy Addresses if "cc" in results["qsd"] and len(results["qsd"]["cc"]): results["cc"] = results["qsd"]["cc"] # Handle Blind Carbon Copy Addresses if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]): results["bcc"] = results["qsd"]["bcc"] # Handle Reply To Addresses if "reply" in results["qsd"] and len(results["qsd"]["reply"]): results["reply_to"] = results["qsd"]["reply"] results["from_addr"] = from_addr results["smtp_host"] = smtp_host # Add our Meta Headers that the user can provide with their outbound # emails results["headers"] = { NotifyBase.unquote(x): NotifyBase.unquote(y) for x, y in results["qsd+"].items() } return results @staticmethod def _get_charset(input_string): """ Get utf-8 charset if non ascii string only Encode an ascii string to utf-8 is bad for email deliverability because some anti-spam gives a bad score for that like SUBJ_EXCESS_QP flag on Rspamd """ if not input_string: return None return "utf-8" if not all(ord(c) < 128 for c in input_string) else None @staticmethod def prepare_emails( subject, body, from_addr, to, cc: Optional[set] = None, bcc: Optional[set] = None, reply_to: Optional[set] = None, # Providing an SMTP Host helps improve Email Message-ID # and avoids getting flagged as spam smtp_host=None, # Can be either 'html' or 'text' notify_format=NotifyFormat.HTML, attach=None, headers: Optional[dict] = None, # Names can be a dictionary names=None, # Pretty Good Privacy Support; Pass in an # ApprisePGPController if you wish to use it pgp=None, # Define our timezone; if one isn't provided, then we use # the system time instead tzinfo=None, ): """ Generator for emails from_addr: must be in format: (from_name, from_addr) to: must be in the format: [(to_name, to_addr), (to_name, to_addr)), ...] cc: must be a set of email addresses bcc: must be a set of email addresses reply_to: must be either None, or an email address smtp_host: This is used to generate the email's Message-ID. Set this correctly to avoid getting flagged as Spam notify_format: can be either 'text' or 'html' attach: must be of class AppriseAttachment headers: Optionally provide a dictionary of additional headers you would like to include in the email payload names: This is a dictionary of email addresses as keys and the Names to associate with them when sending the email. This is cross referenced for the cc and bcc lists pgp: Encrypting the message using Pretty Good Privacy support This requires that the pgp_path provided exists and keys can be referenced here to perform the encryption with. If a key isn't found, one will be generated. pgp support requires the 'PGPy' Python library to be available. Pass in an ApprisePGPController() if you wish to use this """ if not to: # There is no one to email; we're done msg = "There are no Email recipients to notify" logger.warning(msg) raise AppriseEmailException(msg) from None elif pgp and not _pgp.PGP_SUPPORT: msg = "PGP Support unavailable; install PGPy library" logger.warning(msg) raise AppriseEmailException(msg) from None if headers is None: headers = {} if cc is None: cc = set() if bcc is None: bcc = set() if reply_to is None: reply_to = set() if not names: # Prepare a empty dictionary to prevent errors/warnings names = {} if not smtp_host: # Generate a host identifier (used for Message-ID Creation) smtp_host = from_addr[1].split("@")[1] if not tzinfo: # use server time tzinfo = datetime.now().astimezone().tzinfo logger.debug(f"SMTP Host: {smtp_host}") # Create a copy of the targets list emails = list(to) while len(emails): # Get our email to notify to_name, to_addr = emails.pop(0) # Strip target out of cc list if in To or Bcc cc_ = cc - bcc - {to_addr} # Strip target out of bcc list if in To bcc_ = bcc - {to_addr} # Strip target out of reply_to list if in To reply_to_ = reply_to - {to_addr} # Format our cc addresses to support the Name field cc_ = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in cc_ ] # Format our bcc addresses to support the Name field bcc_ = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in bcc_ ] if reply_to_: # Format our reply-to addresses to support the Name field reply_to = [ formataddr((names.get(addr, False), addr), charset="utf-8") for addr in reply_to_ ] logger.debug( "Email From: {}".format(formataddr(from_addr, charset="utf-8")) ) logger.debug("Email To: {}".format(to_addr)) if cc_: logger.debug("Email Cc: {}".format(", ".join(cc_))) if bcc_: logger.debug("Email Bcc: {}".format(", ".join(bcc_))) if reply_to_: logger.debug("Email Reply-To: {}".format(", ".join(reply_to_))) # Prepare Email Message if notify_format == NotifyFormat.HTML: base = MIMEMultipart("alternative") base.attach( MIMEText( convert_between( NotifyFormat.HTML, NotifyFormat.TEXT, body ), "plain", "utf-8", ) ) base.attach(MIMEText(body, "html", "utf-8")) else: base = MIMEText(body, "plain", "utf-8") if attach: mixed = MIMEMultipart("mixed") mixed.attach(base) # Now store our attachments for no, attachment in enumerate(attach, start=1): if not attachment: # We could not load the attachment; take an early # exit since this isn't what the end user wanted # We could not access the attachment msg = "Could not access attachment {}.".format( attachment.url(privacy=True) ) logger.warning(msg) raise AppriseEmailException(msg) logger.debug( "Preparing Email attachment {}".format( attachment.url(privacy=True) ) ) with open(attachment.path, "rb") as abody: app = MIMEApplication(abody.read()) app.set_type(attachment.mimetype) # Prepare our attachment name filename = ( attachment.name if attachment.name else f"file{no:03}.dat" ) app.add_header( "Content-Disposition", 'attachment; filename="{}"'.format( Header(filename, "utf-8") ), ) mixed.attach(app) base = mixed if pgp: logger.debug("Securing Email with PGP Encryption") # Set our header information to include in the encryption base["From"] = formataddr( (None, from_addr[1]), charset="utf-8" ) base["To"] = formataddr((None, to_addr), charset="utf-8") base["Subject"] = Header( subject, NotifyEmail._get_charset(subject) ) # Apply our encryption encrypted_content = pgp.encrypt(base.as_string(), to_addr) if not encrypted_content: # Unable to send notification msg = "Unable to encrypt email via PGP" logger.warning(msg) raise AppriseEmailException(msg) # prepare our message base = MIMEMultipart( "encrypted", protocol="application/pgp-encrypted" ) # Store Autocrypt header (DeltaChat Support) base.add_header( "Autocrypt", f"addr={formataddr((False, to_addr), charset='utf-8')}; " "prefer-encrypt=mutual" ) # Set Encryption Info Part enc_payload = MIMEText("Version: 1", "plain") enc_payload.set_type("application/pgp-encrypted") base.attach(enc_payload) enc_payload = MIMEBase("application", "octet-stream") enc_payload.set_payload(encrypted_content) base.attach(enc_payload) # Apply any provided custom headers for k, v in headers.items(): base[k] = Header(v, NotifyEmail._get_charset(v)) base["Subject"] = Header( subject, NotifyEmail._get_charset(subject) ) base["From"] = formataddr(from_addr, charset="utf-8") base["To"] = formataddr((to_name, to_addr), charset="utf-8") base["Message-ID"] = make_msgid(domain=smtp_host) base["Date"] = format_datetime(datetime.now(tz=tzinfo)) if cc: base["Cc"] = ",".join(cc_) if reply_to_: base["Reply-To"] = ",".join(reply_to) yield EmailMessage( recipient=to_addr, to_addrs=[to_addr, *list(cc_), *list(bcc_)], body=base.as_string(), ) ================================================ FILE: apprise/plugins/email/common.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import dataclasses from ...exception import ApprisePluginException class AppriseEmailException(ApprisePluginException): """ Thrown when there is an error with the Email Attachment """ def __init__(self, message, error_code=601): super().__init__(message, error_code=error_code) class WebBaseLogin: """ This class is just used in conjunction of the default emailers to best formulate a login to it using the data detected """ # User Login must be Email Based EMAIL = "Email" # User Login must UserID Based USERID = "UserID" # Secure Email Modes class SecureMailMode: INSECURE = "insecure" SSL = "ssl" STARTTLS = "starttls" # Define all of the secure modes (used during validation) SECURE_MODES = { SecureMailMode.STARTTLS: { "default_port": 587, }, SecureMailMode.SSL: { "default_port": 465, }, SecureMailMode.INSECURE: { "default_port": 25, }, } @dataclasses.dataclass class EmailMessage: """ Our message structure """ recipient: str to_addrs: list[str] body: str ================================================ FILE: apprise/plugins/email/templates.py ================================================ # BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2026, Chris Caron # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # 1. Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # # 2. Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. import re from .common import SecureMailMode, WebBaseLogin # To attempt to make this script stupid proof, if we detect an email address # that is part of the this table, we can pre-use a lot more defaults if they # aren't otherwise specified on the users input. EMAIL_TEMPLATES = ( # Google GMail ( "Google Mail", re.compile( r"^((?P