diff --git a/KEYWORDS b/KEYWORDS
index 6ce9ca9d..cbd6feee 100644
--- a/KEYWORDS
+++ b/KEYWORDS
@@ -3,7 +3,6 @@ Alerts
Apprise API
Automated Packet Reporting System
AWS
-Boxcar
BulkSMS
BulkVS
Burst SMS
diff --git a/README.md b/README.md
index 3e0f7f3f..84b914bd 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,6 @@ The table below identifies the services this tool supports and some example serv
| [Apprise API](https://github.com/caronc/apprise/wiki/Notify_apprise_api) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token
| [AWS SES](https://github.com/caronc/apprise/wiki/Notify_ses) | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName
ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN
| [Bark](https://github.com/caronc/apprise/wiki/Notify_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
-| [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token
| [Chantify](https://github.com/caronc/apprise/wiki/Notify_chantify) | chantify:// | (TCP) 443 | chantify://token
| [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token
| [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname
diff --git a/apprise/plugins/boxcar.py b/apprise/plugins/boxcar.py
deleted file mode 100644
index f7f16b04..00000000
--- a/apprise/plugins/boxcar.py
+++ /dev/null
@@ -1,404 +0,0 @@
-# -*- coding: utf-8 -*-
-# BSD 2-Clause License
-#
-# Apprise - Push Notification Library.
-# Copyright (c) 2024, 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
-import hmac
-from json import dumps
-from time import time
-from hashlib import sha1
-from itertools import chain
-from urllib.parse import urlparse
-
-from .base import NotifyBase
-from ..url import PrivacyMode
-from ..utils import parse_bool
-from ..utils import parse_list
-from ..utils import validate_regex
-from ..common import NotifyType
-from ..common import NotifyImageSize
-from ..locale import gettext_lazy as _
-
-# Default to sending to all devices if nothing is specified
-DEFAULT_TAG = '@all'
-
-# The tags value is an structure containing an array of strings defining the
-# list of tagged devices that the notification need to be send to, and a
-# boolean operator (‘and’ / ‘or’) that defines the criteria to match devices
-# against those tags.
-IS_TAG = re.compile(r'^[@]?(?P[A-Z0-9]{1,63})$', re.I)
-
-# Device tokens are only referenced when developing.
-# It's not likely you'll send a message directly to a device, but if you do;
-# this plugin supports it.
-IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I)
-
-# Used to break apart list of potential tags by their delimiter into a useable
-# list.
-TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
-
-
-class NotifyBoxcar(NotifyBase):
- """
- A wrapper for Boxcar Notifications
- """
-
- # The default descriptive name associated with the Notification
- service_name = 'Boxcar'
-
- # The services URL
- service_url = 'https://boxcar.io/'
-
- # All boxcar notifications are secure
- secure_protocol = 'boxcar'
-
- # A URL that takes you to the setup/help of the specific protocol
- setup_url = 'https://github.com/caronc/apprise/wiki/Notify_boxcar'
-
- # Boxcar URL
- notify_url = 'https://boxcar-api.io/api/push/'
-
- # Allows the user to specify the NotifyImageSize object
- image_size = NotifyImageSize.XY_72
-
- # The maximum allowable characters allowed in the body per message
- body_maxlen = 10000
-
- # Define object templates
- templates = (
- '{schema}://{access_key}/{secret_key}/',
- '{schema}://{access_key}/{secret_key}/{targets}',
- )
-
- # Define our template tokens
- template_tokens = dict(NotifyBase.template_tokens, **{
- 'access_key': {
- 'name': _('Access Key'),
- 'type': 'string',
- 'private': True,
- 'required': True,
- 'regex': (r'^[A-Z0-9_-]{64}$', 'i'),
- 'map_to': 'access',
- },
- 'secret_key': {
- 'name': _('Secret Key'),
- 'type': 'string',
- 'private': True,
- 'required': True,
- 'regex': (r'^[A-Z0-9_-]{64}$', 'i'),
- 'map_to': 'secret',
- },
- 'target_tag': {
- 'name': _('Target Tag ID'),
- 'type': 'string',
- 'prefix': '@',
- 'regex': (r'^[A-Z0-9]{1,63}$', 'i'),
- 'map_to': 'targets',
- },
- 'target_device': {
- 'name': _('Target Device ID'),
- 'type': 'string',
- 'regex': (r'^[A-Z0-9]{64}$', 'i'),
- 'map_to': 'targets',
- },
- 'targets': {
- 'name': _('Targets'),
- 'type': 'list:string',
- },
- })
-
- # Define our template arguments
- template_args = dict(NotifyBase.template_args, **{
- 'image': {
- 'name': _('Include Image'),
- 'type': 'bool',
- 'default': True,
- 'map_to': 'include_image',
- },
- 'to': {
- 'alias_of': 'targets',
- },
- 'access': {
- 'alias_of': 'access_key',
- },
- 'secret': {
- 'alias_of': 'secret_key',
- },
- })
-
- def __init__(self, access, secret, targets=None, include_image=True,
- **kwargs):
- """
- Initialize Boxcar Object
- """
- super().__init__(**kwargs)
-
- # Initialize tag list
- self._tags = list()
-
- # Initialize device_token list
- self.device_tokens = list()
-
- # Access Key (associated with project)
- self.access = validate_regex(
- access, *self.template_tokens['access_key']['regex'])
- if not self.access:
- msg = 'An invalid Boxcar Access Key ' \
- '({}) was specified.'.format(access)
- self.logger.warning(msg)
- raise TypeError(msg)
-
- # Secret Key (associated with project)
- self.secret = validate_regex(
- secret, *self.template_tokens['secret_key']['regex'])
- if not self.secret:
- msg = 'An invalid Boxcar Secret Key ' \
- '({}) was specified.'.format(secret)
- self.logger.warning(msg)
- raise TypeError(msg)
-
- if not targets:
- self._tags.append(DEFAULT_TAG)
- targets = []
-
- # Validate targets and drop bad ones:
- for target in parse_list(targets):
- result = IS_TAG.match(target)
- if result:
- # store valid tag/alias
- self._tags.append(result.group('name'))
- continue
-
- result = IS_DEVICETOKEN.match(target)
- if result:
- # store valid device
- self.device_tokens.append(target)
- continue
-
- self.logger.warning(
- 'Dropped invalid tag/alias/device_token '
- '({}) specified.'.format(target),
- )
-
- # Track whether or not we want to send an image with our notification
- # or not.
- self.include_image = include_image
-
- return
-
- def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
- """
- Perform Boxcar Notification
- """
- headers = {
- 'User-Agent': self.app_id,
- 'Content-Type': 'application/json'
- }
-
- # prepare Boxcar Object
- payload = {
- 'aps': {
- 'badge': 'auto',
- 'alert': '',
- },
- 'expires': str(int(time() + 30)),
- }
-
- if title:
- payload['aps']['@title'] = title
-
- payload['aps']['alert'] = body
-
- if self._tags:
- payload['tags'] = {'or': self._tags}
-
- if self.device_tokens:
- payload['device_tokens'] = self.device_tokens
-
- # Source picture should be <= 450 DP wide, ~2:1 aspect.
- image_url = None if not self.include_image \
- else self.image_url(notify_type)
-
- if image_url:
- # Set our image
- payload['@img'] = image_url
-
- # Acquire our hostname
- host = urlparse(self.notify_url).hostname
-
- # Calculate signature.
- str_to_sign = "%s\n%s\n%s\n%s" % (
- "POST", host, "/api/push", dumps(payload))
-
- h = hmac.new(
- bytearray(self.secret, 'utf-8'),
- bytearray(str_to_sign, 'utf-8'),
- sha1,
- )
-
- params = NotifyBoxcar.urlencode({
- "publishkey": self.access,
- "signature": h.hexdigest(),
- })
-
- notify_url = '%s?%s' % (self.notify_url, params)
- self.logger.debug('Boxcar POST URL: %s (cert_verify=%r)' % (
- notify_url, self.verify_certificate,
- ))
- self.logger.debug('Boxcar Payload: %s' % str(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,
- verify=self.verify_certificate,
- timeout=self.request_timeout,
- )
-
- # Boxcar returns 201 (Created) when successful
- if r.status_code != requests.codes.created:
- # We had a problem
- status_str = \
- NotifyBoxcar.http_response_code_lookup(r.status_code)
-
- self.logger.warning(
- 'Failed to send Boxcar notification: '
- '{}{}error={}.'.format(
- status_str,
- ', ' if status_str else '',
- r.status_code))
-
- self.logger.debug('Response Details:\r\n{}'.format(r.content))
-
- # Return; we're done
- return False
-
- else:
- self.logger.info('Sent Boxcar notification.')
-
- except requests.RequestException as e:
- self.logger.warning(
- 'A Connection error occurred sending Boxcar '
- 'notification to %s.' % (host))
-
- self.logger.debug('Socket Exception: %s' % str(e))
-
- # Return; we're done
- 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',
- }
-
- # Extend our parameters
- params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
-
- return '{schema}://{access}/{secret}/{targets}?{params}'.format(
- schema=self.secure_protocol,
- access=self.pprint(self.access, privacy, safe=''),
- secret=self.pprint(
- self.secret, privacy, mode=PrivacyMode.Secret, safe=''),
- targets='/'.join([
- NotifyBoxcar.quote(x, safe='') for x in chain(
- self._tags, self.device_tokens) if x != DEFAULT_TAG]),
- params=NotifyBoxcar.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.access, self.secret)
-
- def __len__(self):
- """
- Returns the number of targets associated with this notification
- """
- targets = len(self._tags) + len(self.device_tokens)
- # DEFAULT_TAG is set if no tokens/tags are otherwise set
- return targets if targets > 0 else 1
-
- @staticmethod
- def parse_url(url):
- """
- Parses the URL and returns it broken apart into a dictionary.
-
- """
- results = NotifyBase.parse_url(url, verify_host=False)
- if not results:
- # We're done early
- return None
-
- # The first token is stored in the hostname
- results['access'] = NotifyBoxcar.unquote(results['host'])
-
- # Get our entries; split_path() looks after unquoting content for us
- # by default
- entries = NotifyBoxcar.split_path(results['fullpath'])
-
- # Now fetch the remaining tokens
- results['secret'] = entries.pop(0) if entries else None
-
- # Our recipients make up the remaining entries of our array
- results['targets'] = entries
-
- # The 'to' makes it easier to use yaml configuration
- if 'to' in results['qsd'] and len(results['qsd']['to']):
- results['targets'] += \
- NotifyBoxcar.parse_list(results['qsd'].get('to'))
-
- # Access
- if 'access' in results['qsd'] and results['qsd']['access']:
- results['access'] = NotifyBoxcar.unquote(
- results['qsd']['access'].strip())
-
- # Secret
- if 'secret' in results['qsd'] and results['qsd']['secret']:
- results['secret'] = NotifyBoxcar.unquote(
- results['qsd']['secret'].strip())
-
- # Include images with our message
- results['include_image'] = \
- parse_bool(results['qsd'].get('image', True))
-
- return results
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index 87c31a3b..6c0ee36b 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -39,7 +39,7 @@ Apprise is a Python package for simplifying access to all of the different
notification services that are out there. Apprise opens the door and makes
it easy to access:
-Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, Boxcar, Burst SMS,
+Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, Burst SMS,
BulkSMS, BulkVS, Chantify, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby,
FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home
Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line,
diff --git a/test/test_plugin_boxcar.py b/test/test_plugin_boxcar.py
deleted file mode 100644
index c815b4b1..00000000
--- a/test/test_plugin_boxcar.py
+++ /dev/null
@@ -1,189 +0,0 @@
-# -*- coding: utf-8 -*-
-# BSD 2-Clause License
-#
-# Apprise - Push Notification Library.
-# Copyright (c) 2024, 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 pytest
-from unittest import mock
-
-from apprise.plugins.boxcar import NotifyBoxcar
-from helpers import AppriseURLTester
-from apprise import NotifyType
-import requests
-
-# Disable logging for a cleaner testing output
-import logging
-logging.disable(logging.CRITICAL)
-
-# Our Testing URLs
-apprise_url_tests = (
- ('boxcar://', {
- # invalid secret key
- 'instance': TypeError,
- }),
- # A a bad url
- ('boxcar://:@/', {
- 'instance': TypeError,
- }),
- # No secret specified
- ('boxcar://%s' % ('a' * 64), {
- 'instance': TypeError,
- }),
- # No access specified (whitespace is trimmed)
- ('boxcar://%%20/%s' % ('a' * 64), {
- 'instance': TypeError,
- }),
- # No secret specified (whitespace is trimmed)
- ('boxcar://%s/%%20' % ('a' * 64), {
- 'instance': TypeError,
- }),
- # Provide both an access and a secret
- ('boxcar://%s/%s' % ('a' * 64, 'b' * 64), {
- 'instance': NotifyBoxcar,
- 'requests_response_code': requests.codes.created,
- # Our expected url(privacy=True) startswith() response:
- 'privacy_url': 'boxcar://a...a/****/',
- }),
- # Test without image set
- ('boxcar://%s/%s?image=True' % ('a' * 64, 'b' * 64), {
- 'instance': NotifyBoxcar,
- 'requests_response_code': requests.codes.created,
- # don't include an image in Asset by default
- 'include_image': False,
- }),
- ('boxcar://%s/%s?image=False' % ('a' * 64, 'b' * 64), {
- 'instance': NotifyBoxcar,
- 'requests_response_code': requests.codes.created,
- }),
- # our access, secret and device are all 64 characters
- # which is what we're doing here
- ('boxcar://%s/%s/@tag1/tag2///%s/?to=tag3' % (
- 'a' * 64, 'b' * 64, 'd' * 64), {
- 'instance': NotifyBoxcar,
- 'requests_response_code': requests.codes.created,
- }),
- ('boxcar://?access=%s&secret=%s&to=tag5' % ('d' * 64, 'b' * 64), {
- # Test access and secret kwargs
- 'privacy_url': 'boxcar://d...d/****/',
- 'instance': NotifyBoxcar,
- 'requests_response_code': requests.codes.created,
- }),
- # An invalid tag
- ('boxcar://%s/%s/@%s' % ('a' * 64, 'b' * 64, 't' * 64), {
- 'instance': NotifyBoxcar,
- 'requests_response_code': requests.codes.created,
- }),
- ('boxcar://%s/%s/' % ('a' * 64, 'b' * 64), {
- 'instance': NotifyBoxcar,
- # force a failure
- 'response': False,
- 'requests_response_code': requests.codes.internal_server_error,
- }),
- ('boxcar://%s/%s/' % ('a' * 64, 'b' * 64), {
- 'instance': NotifyBoxcar,
- # throw a bizzare code forcing us to fail to look it up
- 'response': False,
- 'requests_response_code': 999,
- }),
- ('boxcar://%s/%s/' % ('a' * 64, 'b' * 64), {
- 'instance': NotifyBoxcar,
- # Throws a series of connection and transfer exceptions when this flag
- # is set and tests that we gracfully handle them
- 'test_requests_exceptions': True,
- }),
-)
-
-
-def test_plugin_boxcar_urls():
- """
- NotifyBoxcar() Apprise URLs
-
- """
-
- # Run our general tests
- AppriseURLTester(tests=apprise_url_tests).run_all()
-
-
-@mock.patch('requests.get')
-@mock.patch('requests.post')
-def test_plugin_boxcar_edge_cases(mock_post, mock_get):
- """
- NotifyBoxcar() Edge Cases
-
- """
-
- # Generate some generic message types
- device = 'A' * 64
- tag = '@B' * 63
-
- access = '-' * 64
- secret = '_' * 64
-
- # Initializes the plugin with recipients set to None
- NotifyBoxcar(access=access, secret=secret, targets=None)
-
- # Initializes the plugin with a valid access, but invalid access key
- with pytest.raises(TypeError):
- NotifyBoxcar(access=None, secret=secret, targets=None)
-
- # Initializes the plugin with a valid access, but invalid secret
- with pytest.raises(TypeError):
- NotifyBoxcar(access=access, secret=None, targets=None)
-
- # Initializes the plugin with recipients list
- # the below also tests our the variation of recipient types
- NotifyBoxcar(
- access=access, secret=secret, targets=[device, tag])
-
- mock_get.return_value = requests.Request()
- mock_post.return_value = requests.Request()
- mock_post.return_value.status_code = requests.codes.created
- mock_get.return_value.status_code = requests.codes.created
-
- # Test notifications without a body or a title
- p = NotifyBoxcar(access=access, secret=secret, targets=None)
-
- # Neither a title or body was specified
- assert p.notify(
- body=None, title=None, notify_type=NotifyType.INFO) is False
-
- # Acceptable when data is provided:
- assert p.notify(
- body="Test", title=None, notify_type=NotifyType.INFO) is True
-
- # Test comma, separate values
- device = 'a' * 64
- p = NotifyBoxcar(
- access=access, secret=secret,
- targets=','.join([device, device, device]))
- # unique entries are colapsed into 1
- assert len(p.device_tokens) == 1
-
- p = NotifyBoxcar(
- access=access, secret=secret,
- targets=','.join(['a' * 64, 'b' * 64, 'c' * 64]))
- # not unique, so we get the same data here
- assert len(p.device_tokens) == 3