diff --git a/README.md b/README.md
index 4356a4dd..fb3b10ef 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ The table below identifies the services this tool supports and some example serv
| [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN
| [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | tweet:// | (TCP) 443 | tweet://user@CKey/CSecret/AKey/ASecret
| [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port
+| [XMPP](https://github.com/caronc/apprise/wiki/Notify_xmpp) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://password@hostname
xmpp://user:password@hostname
xmpps://user:password@hostname:port?jid=user@hostname/resource
xmpps://password@hostname/target@myhost, target2@myhost/resource
| [Windows Notification](https://github.com/caronc/apprise/wiki/Notify_windows) | windows:// | n/a | windows://
diff --git a/apprise/plugins/NotifyXMPP.py b/apprise/plugins/NotifyXMPP.py
new file mode 100644
index 00000000..de3ef224
--- /dev/null
+++ b/apprise/plugins/NotifyXMPP.py
@@ -0,0 +1,365 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import re
+import six
+import ssl
+from os.path import isfile
+
+from .NotifyBase import NotifyBase
+from ..common import NotifyType
+from ..utils import parse_list
+
+# xep string parser
+XEP_PARSE_RE = re.compile('^[^1-9]*(?P[1-9][0-9]{0,3})$')
+
+# Default our global support flag
+NOTIFY_XMPP_SUPPORT_ENABLED = False
+
+# Taken from https://golang.org/src/crypto/x509/root_linux.go
+CA_CERTIFICATE_FILE_LOCATIONS = [
+ # Debian/Ubuntu/Gentoo etc.
+ "/etc/ssl/certs/ca-certificates.crt",
+ # Fedora/RHEL 6
+ "/etc/pki/tls/certs/ca-bundle.crt",
+ # OpenSUSE
+ "/etc/ssl/ca-bundle.pem",
+ # OpenELEC
+ "/etc/pki/tls/cacert.pem",
+ # CentOS/RHEL 7
+ "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
+]
+
+try:
+ # Import sleekxmpp if available
+ import sleekxmpp
+
+ NOTIFY_XMPP_SUPPORT_ENABLED = True
+
+except ImportError:
+ # No problem; we just simply can't support this plugin because we're
+ # either using Linux, or simply do not have sleekxmpp installed.
+ pass
+
+
+class NotifyXMPP(NotifyBase):
+ """
+ A wrapper for XMPP Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'XMPP'
+
+ # The default protocol
+ protocol = 'xmpp'
+
+ # The default secure protocol
+ secure_protocol = 'xmpps'
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_xmpp'
+
+ # The default XMPP port
+ default_unsecure_port = 5222
+
+ # The default XMPP secure port
+ default_secure_port = 5223
+
+ # XMPP does not support a title
+ title_maxlen = 0
+
+ # This entry is a bit hacky, but it allows us to unit-test this library
+ # in an environment that simply doesn't have the sleekxmpp package
+ # available to us.
+ #
+ # If anyone is seeing this had knows a better way of testing this
+ # outside of what is defined in test/test_xmpp_plugin.py, please
+ # let me know! :)
+ _enabled = NOTIFY_XMPP_SUPPORT_ENABLED
+
+ def __init__(self, targets=None, jid=None, xep=None, to=None, **kwargs):
+ """
+ Initialize XMPP Object
+ """
+ super(NotifyXMPP, self).__init__(**kwargs)
+
+ # JID Details:
+ # - JID's normally have an @ symbol in them, but it is not required
+ # - Each allowable portion of a JID MUST NOT be more than 1023 bytes
+ # in length.
+ # - JID's can identify resource paths at the end separated by slashes
+ # hence the following is valid: user@example.com/resource/path
+
+ # Since JID's can clash with URLs offered by aprise (specifically the
+ # resource paths we need to allow users an alternative character to
+ # represent the slashes. The grammer is defined here:
+ # https://xmpp.org/extensions/xep-0029.html as follows:
+ #
+ # ::= ["@"]["/"]
+ # ::= []*
+ # ::= ["."]*
+ # ::= []*
+ # ::= |[[||"-"]*|]
+ # ::= [a-z] | [A-Z]
+ # ::= [0-9]
+ # ::= #x21 | [#x23-#x25] | [#x28-#x2E] |
+ # [#x30-#x39] | #x3B | #x3D | #x3F |
+ # [#x41-#x7E] | [#x80-#xD7FF] |
+ # [#xE000-#xFFFD] | [#x10000-#x10FFFF]
+ # ::= [#x20-#xD7FF] | [#xE000-#xFFFD] |
+ # [#x10000-#x10FFFF]
+
+ # The best way to do this is to choose characters that aren't allowed
+ # in this case we will use comma and/or space.
+
+ # Assemble our jid using the information available to us:
+ self.jid = jid
+
+ if not (self.user or self.password):
+ # you must provide a jid/pass for this to work; if no password
+ # is specified then the user field acts as the password instead
+ # so we know that if there is no user specified, our url was
+ # really busted up.
+ msg = 'You must specify a XMPP password'
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # See https://xmpp.org/extensions/ for details on xep values
+ if xep is None:
+ # Default xep setting
+ self.xep = [
+ # xep_0030: Service Discovery
+ 30,
+ # xep_0199: XMPP Ping
+ 199,
+ ]
+
+ else:
+ # Prepare the list
+ _xep = parse_list(xep)
+ self.xep = []
+
+ for xep in _xep:
+ result = XEP_PARSE_RE.match(xep)
+ if result is not None:
+ self.xep.append(int(result.group('xep')))
+
+ else:
+ self.logger.warning(
+ "Could not load XMPP xep {}".format(xep))
+
+ # By default we send ourselves a message
+ if targets:
+ self.targets = parse_list(targets)
+
+ else:
+ self.targets = list()
+
+ if isinstance(to, six.string_types):
+ # supporting to= makes yaml configuration easier since the user
+ # just has to identify each user one after another. This is just
+ # an optional extension to also make the url easier to read if
+ # some wish to use it.
+
+ # the to is presumed to be the targets JID
+ self.targets.append(to)
+
+ return
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ """
+ Perform XMPP Notification
+ """
+
+ if not self._enabled:
+ self.logger.warning(
+ 'XMPP Notifications are not supported by this system '
+ '- install sleekxmpp.')
+ return False
+
+ # Detect our JID if it isn't otherwise specified
+ jid = self.jid
+ password = self.password
+ if not jid:
+ if self.user and self.password:
+ # xmpp://user:password@hostname
+ jid = '{}@{}'.format(self.user, self.host)
+
+ else:
+ # xmpp://password@hostname
+ jid = self.host
+ password = self.password if self.password else self.user
+
+ # Prepare our object
+ xmpp = sleekxmpp.ClientXMPP(jid, password)
+
+ for xep in self.xep:
+ # Load xep entries
+ xmpp.register_plugin('xep_{0:04d}'.format(xep))
+
+ if self.secure:
+ xmpp.ssl_version = ssl.PROTOCOL_TLSv1
+ # If the python version supports it, use highest TLS version
+ # automatically
+ if hasattr(ssl, "PROTOCOL_TLS"):
+ # Use the best version of TLS available to us
+ xmpp.ssl_version = ssl.PROTOCOL_TLS
+
+ xmpp.ca_certs = None
+ if self.verify_certificate:
+ # Set the ca_certs variable for certificate verification
+ xmpp.ca_certs = next(
+ (cert for cert in CA_CERTIFICATE_FILE_LOCATIONS
+ if isfile(cert)), None)
+
+ if xmpp.ca_certs is None:
+ self.logger.warning(
+ 'XMPP Secure comunication can not be verified; '
+ 'no CA certificate found')
+
+ # Acquire our port number
+ if not self.port:
+ port = self.default_secure_port \
+ if self.secure else self.default_unsecure_port
+
+ else:
+ port = self.port
+
+ # Establish our connection
+ if not xmpp.connect((self.host, port)):
+ return False
+
+ xmpp.send_presence()
+
+ try:
+ xmpp.get_roster()
+
+ except sleekxmpp.exceptions.IqError as e:
+ self.logger.warning('There was an error getting the XMPP roster.')
+ self.logger.debug(e.iq['error']['condition'])
+ xmpp.disconnect()
+ return False
+
+ except sleekxmpp.exceptions.IqTimeout:
+ self.logger.warning('XMPP Server is taking too long to respond.')
+ xmpp.disconnect()
+ return False
+
+ targets = list(self.targets)
+ if not targets:
+ # We always default to notifying ourselves
+ targets.append(jid)
+
+ while len(targets) > 0:
+
+ # Get next target (via JID)
+ target = targets.pop(0)
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+
+ # The message we wish to send, and the JID that
+ # will receive it.
+ xmpp.send_message(mto=target, mbody=body, mtype='chat')
+
+ # Using wait=True ensures that the send queue will be
+ # emptied before ending the session.
+ xmpp.disconnect(wait=True)
+
+ return True
+
+ def url(self):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any arguments set
+ args = {
+ 'format': self.notify_format,
+ 'overflow': self.overflow_mode,
+ }
+
+ if self.jid:
+ args['jid'] = self.quote(self.jid, safe='')
+
+ if self.xep:
+ args['xep'] = self.quote(
+ ','.join([str(xep) for xep in self.xep]), safe='')
+
+ # Target JID(s) can clash with our existing paths, so we just use comma
+ # and/or space as a delimiters
+ jids = self.quote(' '.join(self.targets), safe='')
+
+ default_port = self.default_secure_port \
+ if self.secure else self.default_unsecure_port
+
+ default_schema = self.secure_protocol if self.secure else self.protocol
+
+ if self.user and self.password:
+ auth = '{}:{}'.format(self.user, self.password)
+
+ else:
+ auth = self.password if self.password else self.user
+
+ return '{schema}://{auth}@{hostname}{port}/{jids}?{args}'.format(
+ auth=self.quote(auth, safe=''),
+ schema=default_schema,
+ hostname=self.host,
+ port='' if not self.port or self.port == default_port
+ else ':{}'.format(self.port),
+ jids=jids,
+ args=self.urlencode(args),
+ )
+
+ @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)
+
+ if not results:
+ # We're done early as we couldn't load the results
+ return results
+
+ # Get our targets; we ignore path slashes since they identify
+ # our resources
+ results['targets'] = parse_list(results['fullpath'])
+
+ # Over-ride the xep plugins
+ if 'xep' in results['qsd'] and len(results['qsd']['xep']):
+ results['xep'] = parse_list(results['qsd']['xep'])
+
+ # Over-ride the default (and detected) jid
+ if 'jid' in results['qsd'] and len(results['qsd']['jid']):
+ results['jid'] = results['qsd']['jid']
+
+ # Over-ride the default (and detected) jid
+ if 'to' in results['qsd'] and len(results['qsd']['to']):
+ results['to'] = results['qsd']['to']
+
+ return results
diff --git a/test/test_xmpp_plugin.py b/test/test_xmpp_plugin.py
new file mode 100644
index 00000000..697c5283
--- /dev/null
+++ b/test/test_xmpp_plugin.py
@@ -0,0 +1,208 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import six
+import mock
+import sys
+# import types
+
+import apprise
+
+try:
+ # Python v3.4+
+ from importlib import reload
+except ImportError:
+ try:
+ # Python v3.0-v3.3
+ from imp import reload
+ except ImportError:
+ # Python v2.7
+ pass
+
+# Disable logging for a cleaner testing output
+import logging
+logging.disable(logging.CRITICAL)
+
+
+def test_xmpp_plugin(tmpdir):
+ """
+ API: NotifyXMPP Plugin()
+
+ """
+
+ # Our module base
+ sleekxmpp_name = 'sleekxmpp'
+
+ # First we do an import without the sleekxmpp library available to ensure
+ # we can handle cases when the library simply isn't available
+
+ if sleekxmpp_name in sys.modules:
+ # Test cases where the sleekxmpp library exists; we want to remove it
+ # for the purpose of testing and capture the handling of the
+ # library when it is missing
+ del sys.modules[sleekxmpp_name]
+ reload(sys.modules['apprise.plugins.NotifyXMPP'])
+
+ # We need to fake our gnome environment for testing purposes since
+ # the sleekxmpp library isn't available in Travis CI
+ sys.modules[sleekxmpp_name] = mock.MagicMock()
+
+ xmpp = mock.Mock()
+ xmpp.register_plugin.return_value = True
+ xmpp.send_message.return_value = True
+ xmpp.connect.return_value = True
+ xmpp.disconnect.return_value = True
+ xmpp.send_presence.return_value = True
+ xmpp.get_roster.return_value = True
+ xmpp.ssl_version = None
+
+ class IqError(Exception):
+ iq = {'error': {'condition': 'test'}}
+ pass
+
+ class IqTimeout(Exception):
+ pass
+
+ # Setup our Exceptions
+ sys.modules[sleekxmpp_name].exceptions.IqError = IqError
+ sys.modules[sleekxmpp_name].exceptions.IqTimeout = IqTimeout
+
+ sys.modules[sleekxmpp_name].ClientXMPP.return_value = xmpp
+
+ # The following libraries need to be reloaded to prevent
+ # TypeError: super(type, obj): obj must be an instance or subtype of type
+ # This is better explained in this StackOverflow post:
+ # https://stackoverflow.com/questions/31363311/\
+ # any-way-to-manually-fix-operation-of-\
+ # super-after-ipython-reload-avoiding-ty
+ #
+ reload(sys.modules['apprise.plugins.NotifyXMPP'])
+ reload(sys.modules['apprise.plugins'])
+ reload(sys.modules['apprise.Apprise'])
+ reload(sys.modules['apprise'])
+
+ # An empty CA list
+ sys.modules['apprise.plugins.NotifyXMPP']\
+ .CA_CERTIFICATE_FILE_LOCATIONS = []
+
+ # Disable Throttling to speed testing
+ apprise.plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0
+
+ # Create our instance
+ obj = apprise.Apprise.instantiate('xmpp://', suppress_exceptions=False)
+
+ # Not possible because no password or host was specified
+ assert obj is None
+
+ try:
+ obj = apprise.Apprise.instantiate(
+ 'xmpp://hostname', suppress_exceptions=False)
+ # We should not reach here; we should have thrown an exception
+ assert False
+
+ except TypeError:
+ # we're good
+ assert True
+
+ # Not possible because no password was specified
+ assert obj is None
+
+ # Try Different Variations of our URL
+ for url in (
+ 'xmpps://user:pass@example.com',
+ 'xmpps://user:pass@example.com?xep=30,199,garbage,xep_99999999',
+ 'xmpps://user:pass@example.com?xep=ignored',
+ 'xmpps://pass@example.com/user@test.com, user2@test.com/resource',
+ 'xmpps://pass@example.com:5226?jid=user@test.com',
+ 'xmpps://pass@example.com?jid=user@test.com&verify=False',
+ 'xmpps://user:pass@example.com?verify=False',
+ 'xmpp://user:pass@example.com?to=user@test.com'):
+
+ obj = apprise.Apprise.instantiate(url, suppress_exceptions=False)
+
+ # Test we loaded
+ assert isinstance(obj, apprise.plugins.NotifyXMPP) is True
+
+ # Check that it found our mocked environments
+ assert obj._enabled is True
+
+ # Test url() call
+ assert isinstance(obj.url(), six.string_types) is True
+
+ # test notifications
+ assert obj.notify(
+ title='title', body='body',
+ notify_type=apprise.NotifyType.INFO) is True
+
+ # test notification without a title
+ assert obj.notify(
+ title='', body='body', notify_type=apprise.NotifyType.INFO) is True
+
+ # Toggle our _enabled flag
+ obj._enabled = False
+
+ # Verify that we can't send content now
+ assert obj.notify(
+ title='', body='body', notify_type=apprise.NotifyType.INFO) is False
+
+ # Toggle it back so it doesn't disrupt other testing
+ obj._enabled = True
+
+ # create an empty file for now
+ ca_cert = tmpdir.mkdir("apprise_xmpp_test").join('ca_cert')
+ ca_cert.write('')
+ # Update our path
+ sys.modules['apprise.plugins.NotifyXMPP']\
+ .CA_CERTIFICATE_FILE_LOCATIONS = [str(ca_cert), ]
+
+ obj = apprise.Apprise.instantiate(
+ 'xmpps://pass@example.com/user@test.com',
+ suppress_exceptions=False)
+
+ # Our notification now should be able to get a ca_cert to reference
+ assert obj.notify(
+ title='', body='body', notify_type=apprise.NotifyType.INFO) is True
+
+ # Test Connect Failures
+ xmpp.connect.return_value = False
+ assert obj.notify(
+ title='', body='body', notify_type=apprise.NotifyType.INFO) is False
+
+ # Return our object value so we don't obstruct other tests
+ xmpp.connect.return_value = True
+
+ # Test Exceptions
+ xmpp.get_roster.side_effect = \
+ sys.modules[sleekxmpp_name].exceptions.IqTimeout()
+
+ assert obj.notify(
+ title='', body='body', notify_type=apprise.NotifyType.INFO) is False
+ xmpp.get_roster.side_effect = None
+
+ xmpp.get_roster.side_effect = \
+ sys.modules[sleekxmpp_name].exceptions.IqError()
+ assert obj.notify(
+ title='', body='body', notify_type=apprise.NotifyType.INFO) is False
+ xmpp.get_roster.side_effect = None