Merge branch 'feature/UpdateApprise' into dev

This commit is contained in:
JackDandy 2025-01-20 23:44:01 +00:00
commit 9840765754
162 changed files with 10359 additions and 2624 deletions

View file

@ -1,5 +1,6 @@
### 3.34.x (2025-xx-xx xx:xx:00 UTC)
* Update apprise 1.8.0 (81caf92) to 1.9.2 (a2a2216)
* Update Beautiful Soup 4.12.3 (7fb5175) to 4.13.0b3 (55e006b)
* Update CacheControl 0.14.0 (e2be0c2) to 0.14.2 (928422d)
* Update certifi 2024.08.30 to 2024.12.14

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -27,10 +27,10 @@
# POSSIBILITY OF SUCH DAMAGE.
__title__ = 'Apprise'
__version__ = '1.8.0'
__version__ = '1.9.2'
__author__ = 'Chris Caron'
__license__ = 'BSD'
__copywrite__ = 'Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>'
__license__ = 'BSD 2-Clause'
__copywrite__ = 'Copyright (C) 2025 Chris Caron <lead2gold@gmail.com>'
__email__ = 'lead2gold@gmail.com'
__status__ = 'Production'
@ -48,16 +48,20 @@ from .common import ContentIncludeMode
from .common import CONTENT_INCLUDE_MODES
from .common import ContentLocation
from .common import CONTENT_LOCATIONS
from .common import PersistentStoreMode
from .common import PERSISTENT_STORE_MODES
from .url import URLBase
from .url import PrivacyMode
from .plugins.base import NotifyBase
from .config.base import ConfigBase
from .attachment.base import AttachBase
from . import exception
from .apprise import Apprise
from .locale import AppriseLocale
from .asset import AppriseAsset
from .persistent_store import PersistentStore
from .apprise_config import AppriseConfig
from .apprise_attachment import AppriseAttachment
from .manager_attachment import AttachmentManager
@ -77,6 +81,10 @@ __all__ = [
# Core
'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase',
'NotifyBase', 'ConfigBase', 'AttachBase', 'AppriseLocale',
'PersistentStore',
# Exceptions
'exception',
# Reference
'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode',
@ -84,6 +92,7 @@ __all__ = [
'ConfigFormat', 'CONFIG_FORMATS',
'ContentIncludeMode', 'CONTENT_INCLUDE_MODES',
'ContentLocation', 'CONTENT_LOCATIONS',
'PersistentStoreMode', 'PERSISTENT_STORE_MODES',
'PrivacyMode',
# Managers

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -32,11 +32,10 @@ import os
from itertools import chain
from . import common
from .conversion import convert_between
from .utils import is_exclusive_match
from .utils.logic import is_exclusive_match
from .utils.parse import parse_list, parse_urls
from .utils.cwe312 import cwe312_url
from .manager_plugins import NotificationManager
from .utils import parse_list
from .utils import parse_urls
from .utils import cwe312_url
from .emojis import apply_emojis
from .logger import logger
from .asset import AppriseAsset

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -33,7 +33,7 @@ from .manager_attachment import AttachmentManager
from .logger import logger
from .common import ContentLocation
from .common import CONTENT_LOCATIONS
from .utils import GET_SCHEMA_RE
from .utils.parse import GET_SCHEMA_RE
# Grant access to our Notification Manager Singleton
A_MGR = AttachmentManager()

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -32,9 +32,8 @@ from .manager_config import ConfigurationManager
from . import URLBase
from .asset import AppriseAsset
from . import common
from .utils import GET_SCHEMA_RE
from .utils import parse_list
from .utils import is_exclusive_match
from .utils.parse import GET_SCHEMA_RE, parse_list
from .utils.logic import is_exclusive_match
from .logger import logger
# Grant access to our Configuration Manager Singleton
@ -46,7 +45,6 @@ class AppriseConfig:
Our Apprise Configuration File Manager
- Supports a list of URLs defined one after another (text format)
- Supports a destinct YAML configuration format
"""
@ -222,7 +220,7 @@ class AppriseConfig:
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
If you know the format ('text') you can specify
it for slightly less overhead during this call. Otherwise the
configuration is auto-detected.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -33,6 +33,7 @@ from os.path import dirname
from os.path import isfile
from os.path import abspath
from .common import NotifyType
from .common import PersistentStoreMode
from .manager_plugins import NotificationManager
@ -142,6 +143,12 @@ class AppriseAsset:
# 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
# For more detail see CWE-312 @
# https://cwe.mitre.org/data/definitions/312.html
#
@ -157,6 +164,22 @@ class AppriseAsset:
# 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)
@ -171,7 +194,9 @@ class AppriseAsset:
# A unique identifer we can use to associate our calling source
_uid = str(uuid4())
def __init__(self, plugin_paths=None, **kwargs):
def __init__(self, plugin_paths=None, storage_path=None,
storage_mode=None, storage_salt=None,
storage_idlen=None, **kwargs):
"""
Asset Initialization
@ -187,8 +212,49 @@ class AppriseAsset:
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
self.__storage_mode = storage_mode
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 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')
else: # Unsupported
raise ValueError(
'AppriseAsset namespace_salt(): Value provided must be '
'string or bytes object')
def color(self, notify_type, color_type=None):
"""
Returns an HTML mapped color based on passed in notify type
@ -356,3 +422,40 @@ class AppriseAsset:
"""
return int(value.lstrip('#'), 16)
@property
def plugin_paths(self):
"""
Return the plugin paths defined
"""
return self.__plugin_paths
@property
def storage_path(self):
"""
Return the persistent storage path defined
"""
return self.__storage_path
@property
def storage_mode(self):
"""
Return the persistent storage mode defined
"""
return self.__storage_mode
@property
def storage_salt(self):
"""
Return the provided namespace salt; this is always of type bytes
"""
return self.__storage_salt
@property
def storage_idlen(self):
"""
Return the persistent storage id length
"""
return self.__storage_idlen

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -29,8 +29,10 @@
import os
import time
import mimetypes
import base64
from .. import exception
from ..url import URLBase
from ..utils import parse_bool
from ..utils.parse import parse_bool
from ..common import ContentLocation
from ..locale import gettext_lazy as _
@ -267,28 +269,60 @@ class AttachBase(URLBase):
cache = self.template_args['cache']['default'] \
if self.cache is None else self.cache
if self.download_path and os.path.isfile(self.download_path) \
and 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
# 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.
try:
age_in_sec = time.time() - os.stat(self.download_path).st_mtime
# 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, IOError):
# The file is not present
pass
except (OSError, IOError):
# 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(
'Could not access attachment {}.'.format(
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 (TypeError, FileNotFoundError):
# We no longer have a path to open
raise exception.AppriseFileNotFound("Attachment Missing")
except (TypeError, OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading {}.'.format(
self.name if self else 'attachment'))
self.logger.debug('I/O Exception: %s' % str(e))
raise exception.AppriseDiskIOError("Attachment Access Error")
def invalidate(self):
"""
Release any temporary data that may be open by child classes.
@ -326,12 +360,27 @@ class AttachBase(URLBase):
def open(self, mode='rb'):
"""
return our file pointer and track it (we'll auto close later
return our file pointer and track it (we'll auto close later)
"""
pointer = open(self.path, mode=mode)
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
@ -398,7 +447,15 @@ class AttachBase(URLBase):
Returns the filesize of the attachment.
"""
return os.path.getsize(self.path) if self.path else 0
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):
"""

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -29,6 +29,7 @@
import re
import os
from .base import AttachBase
from ..utils.disk import path_decode
from ..common import ContentLocation
from ..locale import gettext_lazy as _
@ -57,7 +58,10 @@ class AttachFile(AttachBase):
# Store path but mark it dirty since we have not performed any
# verification at this point.
self.dirty_path = os.path.expanduser(path)
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):
@ -77,7 +81,7 @@ class AttachFile(AttachBase):
params['name'] = self._name
return 'file://{path}{params}'.format(
path=self.quote(self.dirty_path),
path=self.quote(self.__original_path),
params='?{}'.format(self.urlencode(params, safe='/'))
if params else '',
)
@ -97,7 +101,11 @@ class AttachFile(AttachBase):
# Ensure any existing content set has been invalidated
self.invalidate()
if not os.path.isfile(self.dirty_path):
try:
if not os.path.isfile(self.dirty_path):
return False
except OSError:
return False
if self.max_file_size > 0 and \

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -29,7 +29,9 @@
import re
import os
import io
import base64
from .base import AttachBase
from .. import exception
from ..common import ContentLocation
from ..locale import gettext_lazy as _
import uuid
@ -145,6 +147,23 @@ class AttachMemory(AttachBase):
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(
'Could not access attachment {}.'.format(
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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -182,6 +182,42 @@ CONTENT_LOCATIONS = (
ContentLocation.INACCESSIBLE,
)
class PersistentStoreMode:
# 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'
PERSISTENT_STORE_MODES = (
PersistentStoreMode.AUTO,
PersistentStoreMode.FLUSH,
PersistentStoreMode.MEMORY,
)
class PersistentStoreState:
"""
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'
# This is a reserved tag that is automatically assigned to every
# Notification Plugin
MATCH_ALL_TAG = 'all'

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -34,11 +34,8 @@ from .. import plugins
from .. import common
from ..asset import AppriseAsset
from ..url import URLBase
from ..utils import GET_SCHEMA_RE
from ..utils import parse_list
from ..utils import parse_bool
from ..utils import parse_urls
from ..utils import cwe312_url
from ..utils.parse import GET_SCHEMA_RE, parse_list, parse_bool, parse_urls
from ..utils.cwe312 import cwe312_url
from ..manager_config import ConfigurationManager
from ..manager_plugins import NotificationManager

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -29,6 +29,7 @@
import re
import os
from .base import ConfigBase
from ..utils.disk import path_decode
from ..common import ConfigFormat
from ..common import ContentIncludeMode
from ..locale import gettext_lazy as _
@ -59,7 +60,10 @@ class ConfigFile(ConfigBase):
super().__init__(**kwargs)
# Store our file path as it was set
self.path = os.path.abspath(os.path.expanduser(path))
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)
@ -89,7 +93,7 @@ class ConfigFile(ConfigBase):
params['format'] = self.config_format
return 'file://{path}{params}'.format(
path=self.quote(self.path),
path=self.quote(self.__original_path),
params='?{}'.format(self.urlencode(params)) if params else '',
)

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -180,8 +180,10 @@ class HTMLConverter(HTMLParser, object):
self._result.append('\n')
elif tag == 'hr':
if self._result:
if self._result and isinstance(self._result[-1], str):
self._result[-1] = self._result[-1].rstrip(' ')
else:
pass
self._result.append('\n---\n')

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -29,10 +29,8 @@
from ..plugins.base import NotifyBase
from ..manager_plugins import NotificationManager
from ..utils import URL_DETAILS_RE
from ..utils import parse_url
from ..utils import url_assembly
from ..utils import dict_full_update
from ..utils.parse import URL_DETAILS_RE, parse_url, url_assembly
from ..utils.logic import dict_full_update
from .. import common
from ..logger import logger
import inspect
@ -58,6 +56,9 @@ class CustomNotifyPlugin(NotifyBase):
# Support Attachments
attachment_support = True
# Allow persistent storage support
storage_mode = common.PersistentStoreMode.AUTO
# Define object templates
templates = (
'{schema}://',

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

61
lib/apprise/exception.py Normal file
View file

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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 AppriseFileNotFound(AppriseDiskIOError, FileNotFoundError):
"""
Thrown when a persistent write occured in MEMORY mode
"""
def __init__(self, message):
super().__init__(message, error_code=errno.ENOENT)

View file

@ -1,21 +1,21 @@
# Translations template for apprise.
# Copyright (C) 2024 Chris Caron
# Copyright (C) 2025 Chris Caron
# This file is distributed under the same license as the apprise project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2024.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: apprise 1.8.0\n"
"Project-Id-Version: apprise 1.9.2\n"
"Report-Msgid-Bugs-To: lead2gold@gmail.com\n"
"POT-Creation-Date: 2024-05-11 16:13-0400\n"
"POT-Creation-Date: 2025-01-08 21:02-0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.13.1\n"
"Generated-By: Babel 2.16.0\n"
msgid "A local Gnome environment is required."
msgstr ""
@ -35,6 +35,9 @@ msgstr ""
msgid "API Token"
msgstr ""
msgid "API Version"
msgstr ""
msgid "Access Key"
msgstr ""
@ -47,7 +50,7 @@ msgstr ""
msgid "Access Token"
msgstr ""
msgid "Account Email"
msgid "Account Email or Object ID"
msgstr ""
msgid "Account SID"
@ -56,6 +59,9 @@ msgstr ""
msgid "Action"
msgstr ""
msgid "Action Mapping"
msgstr ""
msgid "Add Tokens"
msgstr ""
@ -74,6 +80,12 @@ msgstr ""
msgid "App ID"
msgstr ""
msgid "App Token"
msgstr ""
msgid "App User Name"
msgstr ""
msgid "App Version"
msgstr ""
@ -95,6 +107,9 @@ msgstr ""
msgid "Attach Filename"
msgstr ""
msgid "Auth ID"
msgstr ""
msgid "Auth Token"
msgstr ""
@ -143,15 +158,15 @@ msgstr ""
msgid "Category"
msgstr ""
msgid "Chanify"
msgstr ""
msgid "Channel ID"
msgstr ""
msgid "Channels"
msgstr ""
msgid "Chantify"
msgstr ""
msgid "Class"
msgstr ""
@ -182,6 +197,9 @@ msgstr ""
msgid "Currency"
msgstr ""
msgid "Custom Data"
msgstr ""
msgid "Custom Details"
msgstr ""
@ -200,6 +218,9 @@ msgstr ""
msgid "Data Entries"
msgstr ""
msgid "Decode Template Args"
msgstr ""
msgid "Delay"
msgstr ""
@ -242,9 +263,15 @@ msgstr ""
msgid "Embed URL"
msgstr ""
msgid "Enable Contents"
msgstr ""
msgid "Entity"
msgstr ""
msgid "Entity ID"
msgstr ""
msgid "Event"
msgstr ""
@ -284,6 +311,9 @@ msgstr ""
msgid "Free-Mobile"
msgstr ""
msgid "From"
msgstr ""
msgid "From Email"
msgstr ""
@ -398,6 +428,9 @@ msgstr ""
msgid "Matrix API Verion"
msgstr ""
msgid "Media Type"
msgstr ""
msgid "Memory"
msgstr ""
@ -463,6 +496,12 @@ msgstr ""
msgid "Overflow Mode"
msgstr ""
msgid "PGP Encryption"
msgstr ""
msgid "PGP Public Key Path"
msgstr ""
msgid "Packages are recommended to improve functionality."
msgstr ""
@ -478,15 +517,15 @@ msgstr ""
msgid "Payload Extras"
msgstr ""
msgid "Ping Discord Role"
msgstr ""
msgid "Ping Discord User"
msgid "Persistent Storage"
msgstr ""
msgid "Port"
msgstr ""
msgid "Postback Data"
msgstr ""
msgid "Prefix"
msgstr ""
@ -511,6 +550,9 @@ msgstr ""
msgid "Query Method"
msgstr ""
msgid "Recipient Phone Number"
msgstr ""
msgid "Region"
msgstr ""
@ -532,6 +574,9 @@ msgstr ""
msgid "Resubmit Flag"
msgstr ""
msgid "Retain Messages"
msgstr ""
msgid "Retry"
msgstr ""
@ -541,6 +586,9 @@ msgstr ""
msgid "Route Group"
msgstr ""
msgid "SMS Mode"
msgstr ""
msgid "SMTP Server"
msgstr ""
@ -571,15 +619,27 @@ msgstr ""
msgid "Sender ID"
msgstr ""
msgid "Sender Name"
msgstr ""
msgid "Sensitive Attachments"
msgstr ""
msgid "Server Discovery"
msgstr ""
msgid "Server Key"
msgstr ""
msgid "Server Timeout"
msgstr ""
msgid "Service ID"
msgstr ""
msgid "Service Password"
msgstr ""
msgid "Severity"
msgstr ""
@ -589,9 +649,15 @@ msgstr ""
msgid "Show Status"
msgstr ""
msgid "Signature"
msgstr ""
msgid "Silent Notification"
msgstr ""
msgid "Société Française du Radiotéléphone"
msgstr ""
msgid "Socket Connect Timeout"
msgstr ""
@ -613,9 +679,15 @@ msgstr ""
msgid "Source Phone No"
msgstr ""
msgid "Space ID"
msgstr ""
msgid "Special Text Color"
msgstr ""
msgid "Splunk On-Call"
msgstr ""
msgid "Spoiler Text"
msgstr ""
@ -625,6 +697,9 @@ msgstr ""
msgid "Subtitle"
msgstr ""
msgid "TTS Voice"
msgstr ""
msgid "Tags"
msgstr ""
@ -646,9 +721,6 @@ msgstr ""
msgid "Target Device"
msgstr ""
msgid "Target Device ID"
msgstr ""
msgid "Target Email"
msgstr ""
@ -667,6 +739,9 @@ msgstr ""
msgid "Target Group ID"
msgstr ""
msgid "Target Phone"
msgstr ""
msgid "Target Phone No"
msgstr ""
@ -682,6 +757,9 @@ msgstr ""
msgid "Target Room ID"
msgstr ""
msgid "Target Routing Key"
msgstr ""
msgid "Target Schedule"
msgstr ""
@ -694,9 +772,6 @@ msgstr ""
msgid "Target Subreddit"
msgstr ""
msgid "Target Tag ID"
msgstr ""
msgid "Target Team"
msgstr ""
@ -709,6 +784,9 @@ msgstr ""
msgid "Target User"
msgstr ""
msgid "Target User ID"
msgstr ""
msgid "Targets"
msgstr ""
@ -757,6 +835,9 @@ msgstr ""
msgid "Thread Key"
msgstr ""
msgid "Timeout"
msgstr ""
msgid "To Channel ID"
msgstr ""
@ -778,6 +859,9 @@ msgstr ""
msgid "Token C"
msgstr ""
msgid "Token D"
msgstr ""
msgid "Topic"
msgstr ""
@ -841,6 +925,9 @@ msgstr ""
msgid "Visibility"
msgstr ""
msgid "Volume"
msgstr ""
msgid "Web Based"
msgstr ""
@ -862,9 +949,15 @@ msgstr ""
msgid "Webhook Token"
msgstr ""
msgid "Workflow ID"
msgstr ""
msgid "Workspace"
msgstr ""
msgid "Wrap Text"
msgstr ""
msgid "X-Axis"
msgstr ""

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -33,9 +33,10 @@ import time
import hashlib
import inspect
import threading
from .utils import import_module
from .utils import Singleton
from .utils import parse_list
from .utils.module import import_module
from .utils.singleton import Singleton
from .utils.parse import parse_list
from .utils.disk import path_decode
from os.path import dirname
from os.path import abspath
from os.path import join
@ -223,7 +224,7 @@ class PluginManager(metaclass=Singleton):
if not hasattr(plugin, 'app_id'):
# Filter out non-notification modules
logger.trace(
"(%s) import failed; no app_id defined in %s",
"(%s.%s) import failed; no app_id defined in %s",
self.name, m_class, os.path.join(module_path, f))
continue
@ -243,8 +244,10 @@ class PluginManager(metaclass=Singleton):
for schema in schemas:
if schema in self._schema_map:
logger.error(
"{} schema ({}) mismatch detected - {} to {}"
.format(self.name, schema, self._schema_map,
"{} schema ({}) mismatch detected -"
' {} already maps to {}'
.format(self.name, schema,
self._schema_map[schema],
plugin))
continue
@ -373,7 +376,7 @@ class PluginManager(metaclass=Singleton):
return
for _path in paths:
path = os.path.abspath(os.path.expanduser(_path))
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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,9 +36,8 @@ from ..common import NotifyImageSize
from ..common import NOTIFY_IMAGE_SIZES
from ..common import NotifyType
from ..common import NOTIFY_TYPES
from ..utils import parse_list
from ..utils import cwe312_url
from ..utils import GET_SCHEMA_RE
from ..utils.cwe312 import cwe312_url
from ..utils.parse import parse_list, GET_SCHEMA_RE
from ..logger import logger
from ..locale import gettext_lazy as _
from ..locale import LazyTranslation

View file

@ -0,0 +1,468 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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 .base import NotifyBase
from ..common import NotifyType
from ..utils.parse import (
is_phone_no, parse_bool, parse_phone_no, validate_regex)
from ..locale import gettext_lazy as _
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://github.com/caronc/apprise/wiki/Notify_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',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'apikey': {
'alias_of': 'apikey',
},
'from': {
# Your registered short code or alphanumeric
'name': _('From'),
'type': 'string',
'default': 'AFRICASTKNG',
'map_to': 'sender',
},
'batch': {
'name': _('Batch Mode'),
'type': 'bool',
'default': False,
},
'mode': {
'name': _('SMS Mode'),
'type': 'choice:string',
'values': AFRICAS_TALKING_SMS_MODES,
'default': AFRICAS_TALKING_SMS_MODES[0],
},
})
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 = 'The Africas Talking appuser specified ({}) is invalid.'\
.format(appuser)
self.logger.warning(msg)
raise TypeError(msg)
self.apikey = validate_regex(
apikey, *self.template_tokens['apikey']['regex'])
if not self.apikey:
msg = 'The Africas Talking apikey specified ({}) is invalid.'\
.format(apikey)
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 = 'The Africas Talking mode specified ({}) is invalid.'\
.format(mode)
self.logger.warning(msg)
raise TypeError(msg)
else:
self.mode = self.template_args['mode']['default']
# Parse our targets
self.targets = list()
for target in parse_phone_no(targets):
# Validate targets and drop bad ones:
result = is_phone_no(target)
if not result:
self.logger.warning(
'Dropped invalid phone # '
'({}) specified.'.format(target),
)
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: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate))
self.logger.debug('Africas Talking Payload: %s' % str(payload))
# Printable target detail
p_target = self.targets[index] if batch_size == 1 \
else '{} target(s)'.format(
len(self.targets[index:index + batch_size]))
# 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{}'.format(r.content))
# Mark our failure
has_error = True
continue
else:
self.logger.info(
'Sent Africas Talking notification to {}.'
.format(p_target))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Africas Talking '
'notification to {}.'.format(p_target))
self.logger.debug('Socket Exception: %s' % str(e))
# 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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -29,13 +29,12 @@
import re
import requests
from json import dumps
import base64
from .. import exception
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import parse_list
from ..utils import validate_regex
from ..utils.parse import parse_list, validate_regex
from ..locale import gettext_lazy as _
@ -261,39 +260,45 @@ class NotifyAppriseAPI(NotifyBase):
if not attachment:
# We could not access the attachment
self.logger.error(
'Could not access attachment {}.'.format(
'Could not access Apprise API attachment {}.'.format(
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:
with open(attachment.path, 'rb') as f:
# Output must be in a DataURL format (that's what
# PushSafer calls it):
attachments.append({
'filename': attachment.name,
'base64': base64.b64encode(f.read())
.decode('utf-8'),
'mimetype': attachment.mimetype,
})
# 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((
'file{:02d}'.format(no),
(
attachment.name,
filename,
open(attachment.path, 'rb'),
attachment.mimetype,
)
))
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading {}.'.format(
attachment.name if attachment else 'attachment'))
self.logger.debug('I/O Exception: %s' % str(e))
except (TypeError, OSError, exception.AppriseException):
# We could not access the attachment
self.logger.error(
'Could not access AppriseAPI attachment {}.'.format(
attachment.url(privacy=True)))
return False
self.logger.debug(
'Appending AppriseAPI attachment {}'.format(
attachment.url(privacy=True)))
# prepare Apprise API Object
payload = {
# Apprise API Payload

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -74,8 +74,7 @@ from .base import NotifyBase
from ..locale import gettext_lazy as _
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import is_call_sign
from ..utils import parse_call_sign
from ..utils.parse import is_call_sign, parse_call_sign
from .. import __version__
import re
@ -729,6 +728,15 @@ class NotifyAprs(NotifyBase):
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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,8 +36,7 @@ from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_list
from ..utils import parse_bool
from ..utils.parse import parse_list, parse_bool
from ..locale import gettext_lazy as _
@ -89,11 +88,14 @@ class NotifyBarkLevel:
PASSIVE = 'passive'
CRITICAL = 'critical'
BARK_LEVELS = (
NotifyBarkLevel.ACTIVE,
NotifyBarkLevel.TIME_SENSITIVE,
NotifyBarkLevel.PASSIVE,
NotifyBarkLevel.CRITICAL,
)
@ -178,6 +180,12 @@ class NotifyBark(NotifyBase):
'type': 'choice:string',
'values': BARK_LEVELS,
},
'volume': {
'name': _('Volume'),
'type': 'int',
'min': 0,
'max': 10,
},
'click': {
'name': _('Click'),
'type': 'string',
@ -205,7 +213,7 @@ class NotifyBark(NotifyBase):
def __init__(self, targets=None, include_image=True, sound=None,
category=None, group=None, level=None, click=None,
badge=None, **kwargs):
badge=None, volume=None, **kwargs):
"""
Initialize Notify Bark Object
"""
@ -260,6 +268,19 @@ class NotifyBark(NotifyBase):
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)
# Level
self.level = None if not level else next(
(f for f in BARK_LEVELS if f[0] == level[0]), None)
@ -330,6 +351,9 @@ class NotifyBark(NotifyBase):
if self.group:
payload['group'] = self.group
if self.volume:
payload['volume'] = self.volume
auth = None
if self.user:
auth = (self.user, self.password)
@ -395,6 +419,18 @@ class NotifyBark(NotifyBase):
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.
@ -417,6 +453,9 @@ class NotifyBark(NotifyBase):
if self.level:
params['level'] = self.level
if self.volume:
params['volume'] = str(self.volume)
if self.category:
params['category'] = self.category
@ -490,6 +529,11 @@ class NotifyBark(NotifyBase):
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(

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -32,13 +32,15 @@ from functools import partial
from ..url import URLBase
from ..common import NotifyType
from ..utils import parse_bool
from ..utils.parse import parse_bool
from ..common import NOTIFY_TYPES
from ..common import NotifyFormat
from ..common import NOTIFY_FORMATS
from ..common import OverflowMode
from ..common import OVERFLOW_MODES
from ..common import PersistentStoreMode
from ..locale import gettext_lazy as _
from ..persistent_store import PersistentStore
from ..apprise_attachment import AppriseAttachment
@ -130,12 +132,19 @@ class NotifyBase(URLBase):
# of lines. Setting this to zero disables this feature.
body_max_line_count = 0
# Persistent storage default settings
persistent_storage = True
# 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
@ -197,6 +206,16 @@ class NotifyBase(URLBase):
# 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',
},
})
#
@ -268,6 +287,9 @@ class NotifyBase(URLBase):
# 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:
@ -301,6 +323,14 @@ class NotifyBase(URLBase):
# Provide override
self.overflow_mode = overflow
# 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, logo=False, extension=None,
image_size=None):
"""
@ -726,6 +756,10 @@ class NotifyBase(URLBase):
'overflow': self.overflow_mode,
}
# 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
@ -778,6 +812,10 @@ class NotifyBase(URLBase):
# Allow emoji's override
if 'emojis' in results['qsd']:
results['emojis'] = parse_bool(results['qsd'].get('emojis'))
# Store our persistent storage boolean
if 'store' in results['qsd']:
results['store'] = results['qsd']['store']
return results
@ -798,3 +836,29 @@ class NotifyBase(URLBase):
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

View file

@ -1,395 +0,0 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
#
# 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<name>[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),
)
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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -39,9 +39,7 @@ from itertools import chain
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..utils.parse import is_phone_no, parse_phone_no, parse_bool
from ..locale import gettext_lazy as _
@ -269,7 +267,7 @@ class NotifyBulkSMS(NotifyBase):
'to': None,
'body': body,
'routingGroup': self.route,
'encoding': BulkSMSEncoding.UNICODE \
'encoding': BulkSMSEncoding.UNICODE
if self.unicode else BulkSMSEncoding.TEXT,
# Options are NONE, ALL and ERRORS
'deliveryReports': "ERRORS"
@ -413,6 +411,19 @@ class NotifyBulkSMS(NotifyBase):
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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -38,9 +38,7 @@ import json
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..utils.parse import is_phone_no, parse_phone_no, parse_bool
from ..locale import gettext_lazy as _
@ -304,6 +302,15 @@ class NotifyBulkVS(NotifyBase):
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.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,10 +36,8 @@ import requests
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils.parse import (
is_phone_no, parse_phone_no, parse_bool, validate_regex)
from ..locale import gettext_lazy as _
@ -378,6 +376,15 @@ class NotifyBurstSMS(NotifyBase):
[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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -26,7 +26,7 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# Chantify
# Chanify
# 1. Visit https://chanify.net/
# The API URL will look something like this:
@ -37,26 +37,26 @@ import requests
from .base import NotifyBase
from ..common import NotifyType
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
class NotifyChantify(NotifyBase):
class NotifyChanify(NotifyBase):
"""
A wrapper for Chantify Notifications
A wrapper for Chanify Notifications
"""
# The default descriptive name associated with the Notification
service_name = _('Chantify')
service_name = _('Chanify')
# The services URL
service_url = 'https://chanify.net/'
# The default secure protocol
secure_protocol = 'chantify'
secure_protocol = 'chanify'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_chantify'
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_chanify'
# Notification URL
notify_url = 'https://api.chanify.net/v1/sender/{token}/'
@ -91,14 +91,14 @@ class NotifyChantify(NotifyBase):
def __init__(self, token, **kwargs):
"""
Initialize Chantify Object
Initialize Chanify Object
"""
super().__init__(**kwargs)
self.token = validate_regex(
token, *self.template_tokens['token']['regex'])
if not self.token:
msg = 'The Chantify token specified ({}) is invalid.'\
msg = 'The Chanify token specified ({}) is invalid.'\
.format(token)
self.logger.warning(msg)
raise TypeError(msg)
@ -121,9 +121,9 @@ class NotifyChantify(NotifyBase):
'text': body
}
self.logger.debug('Chantify GET URL: %s (cert_verify=%r)' % (
self.logger.debug('Chanify GET URL: %s (cert_verify=%r)' % (
self.notify_url, self.verify_certificate))
self.logger.debug('Chantify Payload: %s' % str(payload))
self.logger.debug('Chanify Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
@ -139,10 +139,10 @@ class NotifyChantify(NotifyBase):
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyChantify.http_response_code_lookup(r.status_code)
NotifyChanify.http_response_code_lookup(r.status_code)
self.logger.warning(
'Failed to send Chantify notification: '
'Failed to send Chanify notification: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
@ -154,11 +154,11 @@ class NotifyChantify(NotifyBase):
return False
else:
self.logger.info('Sent Chantify notification.')
self.logger.info('Sent Chanify notification.')
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Chantify '
'A Connection error occurred sending Chanify '
'notification.')
self.logger.debug('Socket Exception: %s' % str(e))
@ -178,9 +178,18 @@ class NotifyChantify(NotifyBase):
return '{schema}://{token}/?{params}'.format(
schema=self.secure_protocol,
token=self.pprint(self.token, privacy, safe=''),
params=NotifyChantify.urlencode(params),
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):
"""
@ -198,9 +207,9 @@ class NotifyChantify(NotifyBase):
# Allow over-ride
if 'token' in results['qsd'] and len(results['qsd']['token']):
results['token'] = NotifyChantify.unquote(results['qsd']['token'])
results['token'] = NotifyChanify.unquote(results['qsd']['token'])
else:
results['token'] = NotifyChantify.unquote(results['host'])
results['token'] = NotifyChanify.unquote(results['host'])
return results

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -45,9 +45,7 @@ from json import dumps
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..utils.parse import is_phone_no, parse_phone_no, parse_bool
from ..locale import gettext_lazy as _
# Extend HTTP Error Messages
@ -285,6 +283,15 @@ class NotifyClickSend(NotifyBase):
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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -272,62 +272,6 @@ class NotifyForm(NotifyBase):
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({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our GET params into our parameters
params.update({'-{}'.format(k): v for k, v in self.params.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.payload_extras.items()})
params.update(
{':{}'.format(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 ':{}'.format(self.port),
fullpath=NotifyForm.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyForm.urlencode(params),
)
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
@ -358,7 +302,8 @@ class NotifyForm(NotifyBase):
files.append((
self.attach_as.format(no)
if self.attach_multi_support else self.attach_as, (
attachment.name,
attachment.name
if attachment.name else f'file{no:03}.dat',
open(attachment.path, 'rb'),
attachment.mimetype)
))
@ -486,6 +431,76 @@ class NotifyForm(NotifyBase):
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({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our GET params into our parameters
params.update({'-{}'.format(k): v for k, v in self.params.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.payload_extras.items()})
params.update(
{':{}'.format(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 ':{}'.format(self.port),
fullpath=NotifyForm.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyForm.urlencode(params),
)
@staticmethod
def parse_url(url):
"""

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -27,9 +27,9 @@
# POSSIBILITY OF SUCH DAMAGE.
import requests
import base64
from json import dumps
from .. import exception
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyImageSize
@ -195,56 +195,6 @@ class NotifyJSON(NotifyBase):
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({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our GET params into our parameters
params.update({'-{}'.format(k): v for k, v in self.params.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(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 ':{}'.format(self.port),
fullpath=NotifyJSON.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyJSON.urlencode(params),
)
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
@ -263,33 +213,34 @@ class NotifyJSON(NotifyBase):
# Track our potential attachments
attachments = []
if attach and self.attachment_support:
for attachment in attach:
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 {}.'.format(
'Could not access Custom JSON attachment {}.'.format(
attachment.url(privacy=True)))
return False
try:
with open(attachment.path, 'rb') as f:
# Output must be in a DataURL format (that's what
# PushSafer calls it):
attachments.append({
'filename': attachment.name,
'base64': base64.b64encode(f.read())
.decode('utf-8'),
'mimetype': attachment.mimetype,
})
attachments.append({
"filename": attachment.name
if attachment.name else f'file{no:03}.dat',
'base64': attachment.base64(),
'mimetype': attachment.mimetype,
})
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading {}.'.format(
attachment.name if attachment else 'attachment'))
self.logger.debug('I/O Exception: %s' % str(e))
except exception.AppriseException:
# We could not access the attachment
self.logger.error(
'Could not access Custom JSON attachment {}.'.format(
attachment.url(privacy=True)))
return False
self.logger.debug(
'Appending Custom JSON attachment {}'.format(
attachment.url(privacy=True)))
# Prepare JSON Object
payload = {
JSONPayloadField.VERSION: self.json_version,
@ -395,6 +346,70 @@ class NotifyJSON(NotifyBase):
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({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our GET params into our parameters
params.update({'-{}'.format(k): v for k, v in self.params.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(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 ':{}'.format(self.port),
fullpath=NotifyJSON.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyJSON.urlencode(params),
)
@staticmethod
def parse_url(url):
"""

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -28,8 +28,8 @@
import re
import requests
import base64
from .. import exception
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyImageSize
@ -242,58 +242,6 @@ class NotifyXML(NotifyBase):
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({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our GET params into our parameters
params.update({'-{}'.format(k): v for k, v in self.params.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.payload_extras.items()})
params.update(
{':{}'.format(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 ':{}'.format(self.port),
fullpath=NotifyXML.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyXML.urlencode(params),
)
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
@ -339,35 +287,39 @@ class NotifyXML(NotifyBase):
attachments = []
if attach and self.attachment_support:
for attachment in attach:
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 {}.'.format(
'Could not access Custom XML attachment {}.'.format(
attachment.url(privacy=True)))
return False
try:
with open(attachment.path, 'rb') as f:
# Prepare our Attachment in Base64
entry = \
'<Attachment filename="{}" mimetype="{}">'.format(
NotifyXML.escape_html(
attachment.name, whitespace=False),
NotifyXML.escape_html(
attachment.mimetype, whitespace=False))
entry += base64.b64encode(f.read()).decode('utf-8')
entry += '</Attachment>'
attachments.append(entry)
# Prepare our Attachment in Base64
entry = \
'<Attachment filename="{}" mimetype="{}">'.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 += '</Attachment>'
attachments.append(entry)
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading {}.'.format(
attachment.name if attachment else 'attachment'))
self.logger.debug('I/O Exception: %s' % str(e))
except exception.AppriseException:
# We could not access the attachment
self.logger.error(
'Could not access Custom XML attachment {}.'.format(
attachment.url(privacy=True)))
return False
self.logger.debug(
'Appending Custom XML attachment {}'.format(
attachment.url(privacy=True)))
# Update our xml_attachments record:
xml_attachments = \
'<Attachments format="base64">' + \
@ -467,6 +419,72 @@ class NotifyXML(NotifyBase):
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({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our GET params into our parameters
params.update({'-{}'.format(k): v for k, v in self.params.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.payload_extras.items()})
params.update(
{':{}'.format(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 ':{}'.format(self.port),
fullpath=NotifyXML.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyXML.urlencode(params),
)
@staticmethod
def parse_url(url):
"""

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -41,10 +41,8 @@ from json import loads
from .base import NotifyBase
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import validate_regex
from ..utils import parse_bool
from ..utils.parse import (
is_phone_no, parse_phone_no, validate_regex, parse_bool)
from ..locale import gettext_lazy as _
# Extend HTTP Error Messages
@ -354,6 +352,15 @@ class NotifyD7Networks(NotifyBase):
[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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -55,10 +55,8 @@ from .base import NotifyBase
from ..locale import gettext_lazy as _
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import is_call_sign
from ..utils import parse_call_sign
from ..utils import parse_list
from ..utils import parse_bool
from ..utils.parse import (
is_call_sign, parse_call_sign, parse_list, parse_bool)
class DapnetPriority:
@ -346,6 +344,15 @@ class NotifyDapnet(NotifyBase):
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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -30,7 +30,7 @@ import sys
from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_bool
from ..utils.parse import parse_bool
from ..locale import gettext_lazy as _
# Default our global support flag
@ -174,7 +174,6 @@ class NotifyDBus(NotifyBase):
# 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
# TODO: Review after dropping support for Python 2.
protocol = list(MAINLOOP_MAP.keys())
# A URL that takes you to the setup/help of the specific protocol
@ -197,6 +196,10 @@ class NotifyDBus(NotifyBase):
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}://',

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -38,8 +38,7 @@ from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import parse_list
from ..utils import validate_regex
from ..utils.parse import parse_list, validate_regex
from ..locale import gettext_lazy as _
# Register at https://dingtalk.com
@ -310,6 +309,15 @@ class NotifyDingTalk(NotifyBase):
[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

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -54,8 +54,7 @@ from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils.parse import parse_bool, validate_regex
from ..locale import gettext_lazy as _
from ..attachment.base import AttachBase
@ -597,15 +596,30 @@ class NotifyDiscord(NotifyBase):
if self.thread_id:
params['thread'] = self.thread_id
# 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}://{webhook_id}/{webhook_token}/?{params}'.format(
schema=self.secure_protocol,
webhook_id=self.pprint(self.webhook_id, privacy, safe=''),
webhook_token=self.pprint(self.webhook_token, privacy, safe=''),
params=NotifyDiscord.urlencode(params),
)
return '{schema}://{botname}{webhook_id}/{webhook_token}/?{params}' \
.format(
schema=self.secure_protocol,
botname=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):
"""
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.webhook_id, self.webhook_token)
@staticmethod
def parse_url(url):
@ -660,6 +674,11 @@ class NotifyDiscord(NotifyBase):
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'])
# Extract avatar url if it was specified
if 'avatar_url' in results['qsd']:
results['avatar_url'] = \

View file

@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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 (
AppriseEmailException, EmailMessage, SecureMailMode, SECURE_MODES,
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__ = [
# Reference
'NotifyEmail',
# Pretty Good Privacy
'ApprisePGPController', 'ApprisePGPException',
# Other
'AppriseEmailException', 'EmailMessage', 'SecureMailMode', 'SECURE_MODES',
'WebBaseLogin',
# Additional entries that may be useful to some developers
'EMAIL_TEMPLATES', 'PGP_SUPPORT',
]

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -26,312 +26,32 @@
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import dataclasses
import re
import smtplib
import typing as t
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.utils import formataddr, make_msgid
from email.header import Header
from email import charset
from socket import error as SocketError
from datetime import datetime
from datetime import timezone
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyFormat, NotifyType
from ..conversion import convert_between
from ..utils import is_ipaddr, is_email, parse_emails, is_hostname
from ..locale import gettext_lazy as _
from ..logger import logger
# Globally Default encoding mode set to Quoted Printable.
charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8')
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,
},
}
# 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<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>gmail\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.gmail.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Yandex
(
'Yandex',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>yandex\.(com|ru|ua|by|kz|uz|tr|fr))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.yandex.ru',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.USERID, )
},
),
# Microsoft Hotmail
(
'Microsoft Hotmail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(hotmail|live)\.com(\.au)?)$', re.I),
{
'port': 587,
'smtp_host': 'smtp-mail.outlook.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Microsoft Outlook
(
'Microsoft Outlook',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(smtp\.)?outlook\.com(\.au)?)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.outlook.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Microsoft Office 365 (Email Server)
# You must specify an authenticated sender address in the from= settings
# and a valid email in the to= to deliver your emails to
(
'Microsoft Office 365',
re.compile(
r'^[^@]+@(?P<domain>(smtp\.)?office365\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.office365.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
},
),
# Yahoo Mail
(
'Yahoo Mail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>yahoo\.(ca|com))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.mail.yahoo.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Fast Mail (Series 1)
(
'Fast Mail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>fastmail\.(com|cn|co\.uk|com\.au|de|es|fm|fr|im|'
r'in|jp|mx|net|nl|org|se|to|tw|uk|us))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.fastmail.com',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Fast Mail (Series 2)
(
'Fast Mail Extended Addresses',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>123mail\.org|airpost\.net|eml\.cc|fmail\.co\.uk|'
r'fmgirl\.com|fmguy\.com|mailbolt\.com|mailcan\.com|'
r'mailhaven\.com|mailmight\.com|ml1\.net|mm\.st|myfastmail\.com|'
r'proinbox\.com|promessage\.com|rushpost\.com|sent\.(as|at|com)|'
r'speedymail\.org|warpmail\.net|xsmail\.com|150mail\.com|'
r'150ml\.com|16mail\.com|2-mail\.com|4email\.net|50mail\.com|'
r'allmail\.net|bestmail\.us|cluemail\.com|elitemail\.org|'
r'emailcorner\.net|emailengine\.(net|org)|emailgroups\.net|'
r'emailplus\.org|emailuser\.net|f-m\.fm|fast-email\.com|'
r'fast-mail\.org|fastem\.com|fastemail\.us|fastemailer\.com|'
r'fastest\.cc|fastimap\.com|fastmailbox\.net|fastmessaging\.com|'
r'fea\.st|fmailbox\.com|ftml\.net|h-mail\.us|hailmail\.net|'
r'imap-mail\.com|imap\.cc|imapmail\.org|inoutbox\.com|'
r'internet-e-mail\.com|internet-mail\.org|internetemails\.net|'
r'internetmailing\.net|jetemail\.net|justemail\.net|'
r'letterboxes\.org|mail-central\.com|mail-page\.com|'
r'mailandftp\.com|mailas\.com|mailc\.net|mailforce\.net|'
r'mailftp\.com|mailingaddress\.org|mailite\.com|mailnew\.com|'
r'mailsent\.net|mailservice\.ms|mailup\.net|mailworks\.org|'
r'mymacmail\.com|nospammail\.net|ownmail\.net|petml\.com|'
r'postinbox\.com|postpro\.net|realemail\.net|reallyfast\.biz|'
r'reallyfast\.info|speedpost\.net|ssl-mail\.com|swift-mail\.com|'
r'the-fastest\.net|the-quickest\.com|theinternetemail\.com|'
r'veryfast\.biz|veryspeedy\.net|yepmail\.net)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.fastmail.com',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Zoho Mail (Free)
(
'Zoho Mail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>zoho(mail)?\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.zoho.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# SendGrid (Email Server)
# You must specify an authenticated sender address in the from= settings
# and a valid email in the to= to deliver your emails to
(
'SendGrid',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(\.smtp)?sendgrid\.(com|net))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.sendgrid.net',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.USERID, )
},
),
# 163.com
(
'163.com',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>163\.com)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.163.com',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Foxmail.com
(
'Foxmail.com',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(foxmail|qq)\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.qq.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Comcast.net
(
'Comcast.net',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(comcast)\.net)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.comcast.net',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Catch All
(
'Custom',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>.+)$', re.I),
{
# Setting smtp_host to None is a way of
# auto-detecting it based on other parameters
# specified. There is no reason to ever modify
# this Catch All
'smtp_host': None,
},
),
)
@dataclasses.dataclass
class EmailMessage:
recipient: str
to_addrs: t.List[str]
body: str
from ..base import NotifyBase
from ...url import PrivacyMode
from ...common import NotifyFormat, NotifyType
from ...conversion import convert_between
from ...utils import pgp as _pgp
from ...utils.parse import (
is_ipaddr, is_email, parse_emails, is_hostname, parse_bool)
from ...locale import gettext_lazy as _
from ...logger import logger
from .common import (
AppriseEmailException, EmailMessage, SecureMailMode, SECURE_MODES,
WebBaseLogin)
from . import templates
class NotifyEmail(NotifyBase):
@ -451,6 +171,20 @@ class NotifyEmail(NotifyBase):
'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',
},
})
# Define any kwargs we're using
@ -463,7 +197,7 @@ class NotifyEmail(NotifyBase):
def __init__(self, smtp_host=None, from_addr=None, secure_mode=None,
targets=None, cc=None, bcc=None, reply_to=None, headers=None,
**kwargs):
use_pgp=None, pgp_key=None, **kwargs):
"""
Initialize Email Object
@ -564,7 +298,7 @@ class NotifyEmail(NotifyBase):
)
# Apply any defaults based on certain known configurations
self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs)
self.apply_email_defaults(secure_mode=secure_mode, **kwargs)
if self.user:
if self.host:
@ -635,9 +369,25 @@ class NotifyEmail(NotifyBase):
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 NotifyEmailDefaults(self, secure_mode=None, port=None, **kwargs):
def apply_email_defaults(self, secure_mode=None, port=None, **kwargs):
"""
A function that prefills defaults based on the email
it was provided.
@ -656,39 +406,40 @@ class NotifyEmail(NotifyBase):
self.host,
)
for i in range(len(EMAIL_TEMPLATES)): # pragma: no branch
for i in range(len(templates.EMAIL_TEMPLATES)): # pragma: no branch
self.logger.trace('Scanning %s against %s' % (
from_addr, EMAIL_TEMPLATES[i][0]
from_addr, templates.EMAIL_TEMPLATES[i][0]
))
match = EMAIL_TEMPLATES[i][1].match(from_addr)
match = templates.EMAIL_TEMPLATES[i][1].match(from_addr)
if match:
self.logger.info(
'Applying %s Defaults' %
EMAIL_TEMPLATES[i][0],
templates.EMAIL_TEMPLATES[i][0],
)
# the secure flag can not be altered if defined in the template
self.secure = EMAIL_TEMPLATES[i][2]\
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 = EMAIL_TEMPLATES[i][2]\
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 = EMAIL_TEMPLATES[i][2]\
self.port = templates.EMAIL_TEMPLATES[i][2]\
.get('port', self.port)
if not secure_mode:
self.secure_mode = EMAIL_TEMPLATES[i][2]\
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 = EMAIL_TEMPLATES[i][2].get('login_type', [])
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.
@ -709,153 +460,14 @@ class NotifyEmail(NotifyBase):
break
def _get_charset(self, 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
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
Perform Email Notification
"""
if not self.targets:
# There is no one to email; we're done
self.logger.warning(
'There are no Email recipients to notify')
logger.warning('There are no Email recipients to notify')
return False
messages: t.List[EmailMessage] = []
# Create a copy of the targets list
emails = list(self.targets)
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 = (self.cc - self.bcc - set([to_addr]))
# Strip target out of bcc list if in To
bcc = (self.bcc - set([to_addr]))
# Strip target out of reply_to list if in To
reply_to = (self.reply_to - set([to_addr]))
# Format our cc addresses to support the Name field
cc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in cc]
# Format our bcc addresses to support the Name field
bcc = [formataddr(
(self.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(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in reply_to]
self.logger.debug(
'Email From: {}'.format(
formataddr(self.from_addr, charset='utf-8')))
self.logger.debug('Email To: {}'.format(to_addr))
if cc:
self.logger.debug('Email Cc: {}'.format(', '.join(cc)))
if bcc:
self.logger.debug('Email Bcc: {}'.format(', '.join(bcc)))
if reply_to:
self.logger.debug(
'Email Reply-To: {}'.format(', '.join(reply_to))
)
self.logger.debug('Login ID: {}'.format(self.user))
self.logger.debug(
'Delivery: {}:{}'.format(self.smtp_host, self.port))
# Prepare Email Message
if self.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 and self.attachment_support:
mixed = MIMEMultipart("mixed")
mixed.attach(base)
# Now store our attachments
for attachment in attach:
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
self.logger.error(
'Could not access attachment {}.'.format(
attachment.url(privacy=True)))
return False
self.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)
app.add_header(
'Content-Disposition',
'attachment; filename="{}"'.format(
Header(attachment.name, 'utf-8')),
)
mixed.attach(app)
base = mixed
# Apply any provided custom headers
for k, v in self.headers.items():
base[k] = Header(v, self._get_charset(v))
base['Subject'] = Header(title, self._get_charset(title))
base['From'] = formataddr(self.from_addr, charset='utf-8')
base['To'] = formataddr((to_name, to_addr), charset='utf-8')
base['Message-ID'] = make_msgid(domain=self.smtp_host)
base['Date'] = \
datetime.now(timezone.utc)\
.strftime("%a, %d %b %Y %H:%M:%S +0000")
base['X-Application'] = self.app_id
if cc:
base['Cc'] = ','.join(cc)
if reply_to:
base['Reply-To'] = ','.join(reply_to)
message = EmailMessage(
recipient=to_addr,
to_addrs=[to_addr] + list(cc) + list(bcc),
body=base.as_string())
messages.append(message)
return self.submit(messages)
def submit(self, messages: t.List[EmailMessage]):
# error tracking (used for function return)
has_error = False
@ -884,42 +496,66 @@ class NotifyEmail(NotifyBase):
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 credetials
self.logger.debug('Applying user credentials...')
socket.login(self.user, self.password)
# Send the emails
for message in messages:
# 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):
try:
socket.sendmail(
self.from_addr[1],
message.to_addrs,
message.body)
self.logger.info(
f'Sent Email notification to "{message.recipient}".')
self.logger.info('Sent Email to %s', message.recipient)
except (SocketError, smtplib.SMTPException, RuntimeError) as e:
self.logger.warning(
f'Sending email to "{message.recipient}" failed. '
f'Reason: {e}')
'Sending email to "%s" failed.', message.recipient)
self.logger.debug(f'Socket Exception: {e}')
# Mark as failure
has_error = True
except (SocketError, smtplib.SMTPException, RuntimeError) as e:
self.logger.warning(
f'Connection error while submitting email to {self.smtp_host}.'
f' Reason: {e}')
'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: # pragma: no branch
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):
@ -928,7 +564,13 @@ class NotifyEmail(NotifyBase):
"""
# Define an URL parameters
params = {}
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()})
@ -961,7 +603,7 @@ class NotifyEmail(NotifyBase):
params['from'] = \
formataddr((False, self.from_addr[1]), charset='utf-8')
if len(self.cc) > 0:
if self.cc:
# Handle our Carbon Copy Addresses
params['cc'] = ','.join([
formataddr(
@ -971,7 +613,7 @@ class NotifyEmail(NotifyBase):
charset='utf-8').replace(',', '%2C')
for e in self.cc])
if len(self.bcc) > 0:
if self.bcc:
# Handle our Blind Carbon Copy Addresses
params['bcc'] = ','.join([
formataddr(
@ -1031,12 +673,25 @@ class NotifyEmail(NotifyBase):
params=NotifyEmail.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 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
"""
targets = len(self.targets)
return targets if targets > 0 else 1
return len(self.targets) if self.targets else 1
@staticmethod
def parse_url(url):
@ -1068,6 +723,16 @@ class NotifyEmail(NotifyBase):
# 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.
@ -1089,14 +754,9 @@ class NotifyEmail(NotifyBase):
from_addr = NotifyEmail.unquote(results['qsd']['from'])
if 'name' in results['qsd'] and len(results['qsd']['name']):
# Depricate use of both `from=` and `name=` in the same url as
# they will be synomomus of one another in the future.
from_addr = formataddr(
(NotifyEmail.unquote(results['qsd']['name']), from_addr),
charset='utf-8')
logger.warning(
'Email name= and from= are synonymous; '
'use one or the other.')
elif 'name' in results['qsd'] and len(results['qsd']['name']):
# Extract from name to associate with from address
@ -1132,3 +792,233 @@ class NotifyEmail(NotifyBase):
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=set(), bcc=set(), reply_to=set(),
# 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=dict(),
# Names can be a dictionary
names=None,
# Pretty Good Privacy Support; Pass in an
# ApprisePGPController if you wish to use it
pgp=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)
elif pgp and not _pgp.PGP_SUPPORT:
msg = 'PGP Support unavailable; install PGPy library'
logger.warning(msg)
raise AppriseEmailException(msg)
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]
logger.debug('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 - set([to_addr]))
# Strip target out of bcc list if in To
_bcc = (bcc - set([to_addr]))
# Strip target out of reply_to list if in To
_reply_to = (reply_to - set([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 messsage
base = MIMEMultipart(
"encrypted", protocol="application/pgp-encrypted")
# Store Autocrypt header (DeltaChat Support)
base.add_header(
"Autocrypt",
"addr=%s; prefer-encrypt=mutual" % formataddr(
(False, to_addr), charset='utf-8'))
# 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'] = \
datetime.now(timezone.utc)\
.strftime("%a, %d %b %Y %H:%M:%S +0000")
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())

View file

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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
import typing as t
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: t.List[str]
body: str

View file

@ -0,0 +1,272 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>gmail\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.gmail.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Yandex
(
'Yandex',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>yandex\.(com|ru|ua|by|kz|uz|tr|fr))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.yandex.ru',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.USERID, )
},
),
# Microsoft Hotmail
(
'Microsoft Hotmail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(hotmail|live)\.com(\.au)?)$', re.I),
{
'port': 587,
'smtp_host': 'smtp-mail.outlook.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Microsoft Outlook
(
'Microsoft Outlook',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(smtp\.)?outlook\.com(\.au)?)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.outlook.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Microsoft Office 365 (Email Server)
# You must specify an authenticated sender address in the from= settings
# and a valid email in the to= to deliver your emails to
(
'Microsoft Office 365',
re.compile(
r'^[^@]+@(?P<domain>(smtp\.)?office365\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.office365.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
},
),
# Yahoo Mail
(
'Yahoo Mail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>yahoo\.(ca|com))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.mail.yahoo.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Fast Mail (Series 1)
(
'Fast Mail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>fastmail\.(com|cn|co\.uk|com\.au|de|es|fm|fr|im|'
r'in|jp|mx|net|nl|org|se|to|tw|uk|us))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.fastmail.com',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Fast Mail (Series 2)
(
'Fast Mail Extended Addresses',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>123mail\.org|airpost\.net|eml\.cc|fmail\.co\.uk|'
r'fmgirl\.com|fmguy\.com|mailbolt\.com|mailcan\.com|'
r'mailhaven\.com|mailmight\.com|ml1\.net|mm\.st|myfastmail\.com|'
r'proinbox\.com|promessage\.com|rushpost\.com|sent\.(as|at|com)|'
r'speedymail\.org|warpmail\.net|xsmail\.com|150mail\.com|'
r'150ml\.com|16mail\.com|2-mail\.com|4email\.net|50mail\.com|'
r'allmail\.net|bestmail\.us|cluemail\.com|elitemail\.org|'
r'emailcorner\.net|emailengine\.(net|org)|emailgroups\.net|'
r'emailplus\.org|emailuser\.net|f-m\.fm|fast-email\.com|'
r'fast-mail\.org|fastem\.com|fastemail\.us|fastemailer\.com|'
r'fastest\.cc|fastimap\.com|fastmailbox\.net|fastmessaging\.com|'
r'fea\.st|fmailbox\.com|ftml\.net|h-mail\.us|hailmail\.net|'
r'imap-mail\.com|imap\.cc|imapmail\.org|inoutbox\.com|'
r'internet-e-mail\.com|internet-mail\.org|internetemails\.net|'
r'internetmailing\.net|jetemail\.net|justemail\.net|'
r'letterboxes\.org|mail-central\.com|mail-page\.com|'
r'mailandftp\.com|mailas\.com|mailc\.net|mailforce\.net|'
r'mailftp\.com|mailingaddress\.org|mailite\.com|mailnew\.com|'
r'mailsent\.net|mailservice\.ms|mailup\.net|mailworks\.org|'
r'mymacmail\.com|nospammail\.net|ownmail\.net|petml\.com|'
r'postinbox\.com|postpro\.net|realemail\.net|reallyfast\.biz|'
r'reallyfast\.info|speedpost\.net|ssl-mail\.com|swift-mail\.com|'
r'the-fastest\.net|the-quickest\.com|theinternetemail\.com|'
r'veryfast\.biz|veryspeedy\.net|yepmail\.net)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.fastmail.com',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Zoho Mail (Free)
(
'Zoho Mail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>zoho(mail)?\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.zoho.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# SendGrid (Email Server)
# You must specify an authenticated sender address in the from= settings
# and a valid email in the to= to deliver your emails to
(
'SendGrid',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(\.smtp)?sendgrid\.(com|net))$', re.I),
{
'port': 465,
'smtp_host': 'smtp.sendgrid.net',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.USERID, )
},
),
# 163.com
(
'163.com',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>163\.com)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.163.com',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Foxmail.com
(
'Foxmail.com',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(foxmail|qq)\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.qq.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Comcast.net
(
'Comcast.net',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(comcast)\.net)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.comcast.net',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Catch All
(
'Custom',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>.+)$', re.I),
{
# Setting smtp_host to None is a way of
# auto-detecting it based on other parameters
# specified. There is no reason to ever modify
# this Catch All
'smtp_host': None,
},
),
)

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,7 +36,7 @@ from json import loads
from .base import NotifyBase
from ..url import PrivacyMode
from ..utils import parse_bool
from ..utils.parse import parse_bool
from ..common import NotifyType
from .. import __version__ as VERSION
from ..locale import gettext_lazy as _
@ -593,6 +593,18 @@ class NotifyEmby(NotifyBase):
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.user, self.password, self.host,
self.port if self.port else (443 if self.secure else 80),
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -181,6 +181,20 @@ class NotifyEnigma2(NotifyBase):
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.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.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -52,10 +52,8 @@ import requests
from json import dumps
from ..base import NotifyBase
from ...common import NotifyType
from ...utils import validate_regex
from ...utils import parse_list
from ...utils import parse_bool
from ...utils import dict_full_update
from ...utils.parse import validate_regex, parse_list, parse_bool
from ...utils.logic import dict_full_update
from ...common import NotifyImageSize
from ...apprise_attachment import AppriseAttachment
from ...locale import gettext_lazy as _
@ -507,6 +505,15 @@ class NotifyFCM(NotifyBase):
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.mode, self.apikey, self.project)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -34,7 +34,7 @@
# https://firebase.google.com/docs/reference/fcm/rest/v1/\
# projects.messages#androidnotification
import re
from ...utils import parse_bool
from ...utils.parse import parse_bool
from ...common import NotifyType
from ...asset import AppriseAsset

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -38,7 +38,7 @@ from json import dumps
from .base import NotifyBase
from ..common import NotifyType
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
@ -192,6 +192,15 @@ class NotifyFeishu(NotifyBase):
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, self.token)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -48,9 +48,7 @@ from .base import NotifyBase
from ..common import NotifyType
from ..common import NotifyFormat
from ..common import NotifyImageSize
from ..utils import parse_list
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils.parse import parse_list, parse_bool, validate_regex
from ..locale import gettext_lazy as _
@ -308,6 +306,15 @@ class NotifyFlock(NotifyBase):
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.token)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -103,6 +103,15 @@ class NotifyFreeMobile(NotifyBase):
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.user, self.password)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -29,7 +29,7 @@
from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_bool
from ..utils.parse import parse_bool
from ..locale import gettext_lazy as _
# Default our global support flag
@ -134,6 +134,10 @@ class NotifyGnome(NotifyBase):
# cause any title (if defined) to get placed into the message body.
title_maxlen = 0
# 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}://',

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -61,7 +61,7 @@ from json import dumps
from .base import NotifyBase
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
@ -265,6 +265,18 @@ class NotifyGoogleChat(NotifyBase):
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, self.workspace, self.webhook_key,
self.webhook_token,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,7 +36,7 @@ from json import dumps
from .base import NotifyBase
from ..common import NotifyType, NotifyFormat
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
@ -265,6 +265,20 @@ class NotifyGotify(NotifyBase):
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.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -30,7 +30,7 @@ from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_bool
from ..utils.parse import parse_bool
from ..locale import gettext_lazy as _
# Default our global support flag
@ -338,6 +338,19 @@ class NotifyGrowl(NotifyBase):
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 self.default_port,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -37,7 +37,7 @@ from uuid import uuid4
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
@ -179,8 +179,8 @@ class NotifyHomeAssistant(NotifyBase):
if isinstance(self.port, int):
url += ':%d' % self.port
url += '' if not self.fullpath else '/' + self.fullpath.strip('/')
url += '/api/services/persistent_notification/create'
url += self.fullpath.rstrip('/') + \
'/api/services/persistent_notification/create'
self.logger.debug('Home Assistant POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate,
@ -231,6 +231,22 @@ class NotifyHomeAssistant(NotifyBase):
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 self.default_insecure_port),
self.fullpath.rstrip('/'),
self.accesstoken,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -302,7 +318,7 @@ class NotifyHomeAssistant(NotifyBase):
results['accesstoken'] = fullpath.pop() if fullpath else None
# Re-assemble our full path
results['fullpath'] = '/'.join(fullpath)
results['fullpath'] = '/' + '/'.join(fullpath) if fullpath else ''
# Allow the specification of a unique notification_id so that
# it will always replace the last one sent.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -33,9 +33,7 @@ import requests
import json
from .base import NotifyBase
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import validate_regex
from ..utils.parse import is_phone_no, parse_phone_no, validate_regex
from ..locale import gettext_lazy as _
@ -253,6 +251,15 @@ class NotifyHttpSMS(NotifyBase):
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.apikey)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -46,8 +46,7 @@ from json import dumps
from .base import NotifyBase
from ..common import NotifyType
from ..utils import parse_list
from ..utils import validate_regex
from ..utils.parse import parse_list, validate_regex
from ..locale import gettext_lazy as _
@ -287,6 +286,15 @@ class NotifyIFTTT(NotifyBase):
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.webhook_id)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -42,9 +42,7 @@ import requests
from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_list
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils.parse import parse_list, parse_bool, validate_regex
from ..locale import gettext_lazy as _
# Extend HTTP Error Messages
@ -345,6 +343,15 @@ class NotifyJoin(NotifyBase):
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.apikey)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -40,9 +40,7 @@ from json import loads
from .base import NotifyBase
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import validate_regex
from ..utils.parse import is_phone_no, parse_phone_no, validate_regex
from ..locale import gettext_lazy as _
# Extend HTTP Error Messages
@ -304,6 +302,15 @@ class NotifyKavenegar(NotifyBase):
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.apikey)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -41,7 +41,7 @@ from json import dumps
from .base import NotifyBase
from ..common import NotifyType
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
# Extend HTTP Error Messages
@ -198,6 +198,15 @@ class NotifyKumulos(NotifyBase):
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, self.apikey, self.serverkey)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -92,10 +92,8 @@ import requests
from json import dumps
from .base import NotifyBase
from ..common import NotifyType
from ..utils import validate_regex
from ..locale import gettext_lazy as _
from ..utils import is_hostname
from ..utils import is_ipaddr
from ..utils.parse import validate_regex, is_hostname, is_ipaddr
# A URL Parser to detect App ID
LAMETRIC_APP_ID_DETECTOR_RE = re.compile(
@ -783,6 +781,29 @@ class NotifyLametric(NotifyBase):
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.
"""
if self.mode == LametricMode.DEVICE:
return (
self.secure_protocol if self.secure else self.protocol,
self.user, self.lametric_apikey, self.host,
self.port if self.port else (
443 if self.secure else
self.template_tokens['port']['default']),
)
return (
self.protocol,
self.lametric_app_access_token,
self.lametric_app_id,
self.lametric_app_ver,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -871,6 +892,9 @@ class NotifyLametric(NotifyBase):
results['password'] = results['user']
results['user'] = None
# Get unquoted entries
entries = NotifyLametric.split_path(results['fullpath'])
# Priority Handling
if 'priority' in results['qsd'] and results['qsd']['priority']:
results['priority'] = NotifyLametric.unquote(
@ -913,6 +937,10 @@ class NotifyLametric(NotifyBase):
results['app_ver'] = \
NotifyLametric.unquote(results['qsd']['app_ver'])
elif entries:
# Store our app id
results['app_ver'] = entries.pop(0)
if 'token' in results['qsd'] and results['qsd']['token']:
# Extract Application Access Token from an argument
results['app_token'] = \
@ -938,7 +966,7 @@ class NotifyLametric(NotifyBase):
LAMETRIC_IS_APP_TOKEN.match(results['password'])) and
# Scan for app_ flags
next((f for f in results.keys() \
next((f for f in results.keys()
if f.startswith('app_')), None) is None) \
else LametricMode.CLOUD

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -37,9 +37,7 @@ from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..common import NotifyImageSize
from ..utils import validate_regex
from ..utils import parse_list
from ..utils import parse_bool
from ..utils.parse import validate_regex, parse_list, parse_bool
from ..locale import gettext_lazy as _
@ -241,6 +239,15 @@ class NotifyLine(NotifyBase):
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.token)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,10 +36,8 @@ from json import dumps
from .base import NotifyBase
from ..common import NotifyType
from ..common import NotifyImageSize
from ..utils import parse_list
from ..utils import is_hostname
from ..utils import is_ipaddr
from ..utils import parse_bool
from ..utils.parse import (
parse_list, is_hostname, is_ipaddr, parse_bool)
from ..locale import gettext_lazy as _
from ..url import PrivacyMode
@ -324,6 +322,24 @@ class NotifyLunaSea(NotifyBase):
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.
"""
secure = self.secure_protocol[0] \
if self.mode == LunaSeaMode.CLOUD else (
self.secure_protocol[0] if self.secure else self.protocol[0])
return (
secure,
self.host if self.mode == LunaSeaMode.PRIVATE else None,
self.port if self.port else (443 if self.secure else 80),
self.user if self.user else None,
self.password if self.password else None,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -33,7 +33,7 @@ import os
from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_bool
from ..utils.parse import parse_bool
from ..locale import gettext_lazy as _
# Default our global support flag
@ -92,6 +92,10 @@ class NotifyMacOSX(NotifyBase):
# content to display
body_max_line_count = 10
# 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
# The possible paths to the terminal-notifier
notify_paths = (
'/opt/homebrew/bin/terminal-notifier',

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -59,10 +59,8 @@ from email.utils import formataddr
from .base import NotifyBase
from ..common import NotifyType
from ..common import NotifyFormat
from ..utils import parse_emails
from ..utils import parse_bool
from ..utils import is_email
from ..utils import validate_regex
from ..utils.parse import (
parse_emails, parse_bool, is_email, validate_regex)
from ..logger import logger
from ..locale import gettext_lazy as _
@ -383,9 +381,15 @@ class NotifyMailgun(NotifyBase):
self.logger.debug(
'Preparing Mailgun attachment {}'.format(
attachment.url(privacy=True)))
# Prepare our filename
filename = attachment.name \
if attachment.name \
else 'file{no:03}.dat'.format(no=idx + 1)
try:
files['attachment[{}]'.format(idx)] = \
(attachment.name, open(attachment.path, 'rb'))
(filename, open(attachment.path, 'rb'))
except (OSError, IOError) as e:
self.logger.warning(
@ -579,6 +583,17 @@ class NotifyMailgun(NotifyBase):
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.host, self.apikey, self.region_name,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -38,9 +38,7 @@ from ..url import PrivacyMode
from ..common import NotifyImageSize
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import parse_list
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils.parse import parse_list, parse_bool, validate_regex
from ..locale import gettext_lazy as _
from ..attachment.base import AttachBase
@ -336,6 +334,18 @@ class NotifyMastodon(NotifyBase):
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[0], self.token, self.host,
self.port if self.port else (443 if self.secure else 80),
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -32,6 +32,7 @@
#
import re
import requests
import uuid
from markdown import markdown
from json import dumps
from json import loads
@ -39,13 +40,13 @@ from time import time
from .base import NotifyBase
from ..url import PrivacyMode
from ..exception import AppriseException
from ..common import NotifyType
from ..common import NotifyImageSize
from ..common import NotifyFormat
from ..utils import parse_bool
from ..utils import parse_list
from ..utils import is_hostname
from ..utils import validate_regex
from ..common import PersistentStoreMode
from ..utils.parse import (
parse_bool, parse_list, is_hostname, validate_regex)
from ..locale import gettext_lazy as _
# Define default path
@ -55,6 +56,13 @@ MATRIX_V3_API_PATH = '/_matrix/client/v3'
MATRIX_V3_MEDIA_PATH = '/_matrix/media/v3'
MATRIX_V2_MEDIA_PATH = '/_matrix/media/r0'
class MatrixDiscoveryException(AppriseException):
"""
Apprise Matrix Exception Class
"""
# Extend HTTP Error Messages
MATRIX_HTTP_ERROR_MAP = {
403: 'Unauthorized - Invalid Token.',
@ -164,9 +172,6 @@ class NotifyMatrix(NotifyBase):
# Throttle a wee-bit to avoid thrashing
request_rate_per_sec = 0.5
# Our Matrix API Version
matrix_api_version = '3'
# How many retry attempts we'll make in the event the server asks us to
# throttle back.
default_retries = 2
@ -175,15 +180,31 @@ class NotifyMatrix(NotifyBase):
# the server doesn't remind us how long we shoul wait for
default_wait_ms = 1000
# Our default is to no not use persistent storage beyond in-memory
# reference
storage_mode = PersistentStoreMode.AUTO
# Keep our cache for 20 days
default_cache_expiry_sec = 60 * 60 * 24 * 20
# Used for server discovery
discovery_base_key = '__discovery_base'
discovery_identity_key = '__discovery_identity'
# Defines how long we cache our discovery for
discovery_cache_length_sec = 86400
# Define object templates
templates = (
# Targets are ignored when using t2bot mode; only a token is required
'{schema}://{token}',
'{schema}://{user}@{token}',
# Disabled webhook
# Matrix Server
'{schema}://{user}:{password}@{host}/{targets}',
'{schema}://{user}:{password}@{host}:{port}/{targets}',
'{schema}://{token}@{host}/{targets}',
'{schema}://{token}@{host}:{port}/{targets}',
# Webhook mode
'{schema}://{user}:{token}@{host}/{targets}',
@ -248,6 +269,11 @@ class NotifyMatrix(NotifyBase):
'default': False,
'map_to': 'include_image',
},
'discovery': {
'name': _('Server Discovery'),
'type': 'bool',
'default': True,
},
'mode': {
'name': _('Webhook Mode'),
'type': 'choice:string',
@ -275,7 +301,7 @@ class NotifyMatrix(NotifyBase):
})
def __init__(self, targets=None, mode=None, msgtype=None, version=None,
include_image=False, **kwargs):
include_image=None, discovery=None, **kwargs):
"""
Initialize Matrix Object
"""
@ -297,11 +323,12 @@ class NotifyMatrix(NotifyBase):
self.transaction_id = 0
# Place an image inline with the message body
self.include_image = include_image
self.include_image = self.template_args['image']['default'] \
if include_image is None else include_image
# maintain a lookup of room alias's we already paired with their id
# to speed up future requests
self._room_cache = {}
# Prepare Delegate Server Lookup Check
self.discovery = self.template_args['discovery']['default'] \
if discovery is None else discovery
# Setup our mode
self.mode = self.template_args['mode']['default'] \
@ -342,6 +369,7 @@ class NotifyMatrix(NotifyBase):
.format(self.host)
self.logger.warning(msg)
raise TypeError(msg)
else:
# Verify port if specified
if self.port is not None and not (
@ -353,6 +381,27 @@ class NotifyMatrix(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
if self.mode != MatrixWebhookMode.DISABLED:
# Discovery only works when we're not using webhooks
self.discovery = False
#
# Initialize from cache if present
#
if self.mode != MatrixWebhookMode.T2BOT:
# our home server gets populated after a login/registration
self.home_server = self.store.get('home_server')
# our user_id gets populated after a login/registration
self.user_id = self.store.get('user_id')
# This gets initialized after a login/registration
self.access_token = self.store.get('access_token')
# This gets incremented for each request made against the v3 API
self.transaction_id = 0 if not self.access_token \
else self.store.get('transaction_id', 0)
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Matrix Notification
@ -564,6 +613,10 @@ class NotifyMatrix(NotifyBase):
Perform Direct Matrix Server Notification (no webhook)
"""
if self.access_token is None and self.password and not self.user:
self.access_token = self.password
self.transaction_id = uuid.uuid4()
if self.access_token is None:
# We need to register
if not self._login():
@ -693,8 +746,12 @@ class NotifyMatrix(NotifyBase):
# Increment the transaction ID to avoid future messages being
# recognized as retransmissions and ignored
if self.version == MatrixVersion.V3:
if self.version == MatrixVersion.V3 \
and self.access_token != self.password:
self.transaction_id += 1
self.store.set(
'transaction_id', self.transaction_id,
expires=self.default_cache_expiry_sec)
if not postokay:
# Notify our user
@ -811,7 +868,18 @@ class NotifyMatrix(NotifyBase):
self.home_server = response.get('home_server')
self.user_id = response.get('user_id')
self.store.set(
'access_token', self.access_token,
expires=self.default_cache_expiry_sec)
self.store.set(
'home_server', self.home_server,
expires=self.default_cache_expiry_sec)
self.store.set(
'user_id', self.user_id,
expires=self.default_cache_expiry_sec)
if self.access_token is not None:
# Store our token into our store
self.logger.debug(
'Registered successfully with Matrix server.')
return True
@ -828,32 +896,33 @@ class NotifyMatrix(NotifyBase):
# Login not required; silently skip-over
return True
if not (self.user and self.password):
if (self.user and self.password):
# Prepare our Authentication Payload
if self.version == MatrixVersion.V3:
payload = {
'type': 'm.login.password',
'identifier': {
'type': 'm.id.user',
'user': self.user,
},
'password': self.password,
}
else:
payload = {
'type': 'm.login.password',
'user': self.user,
'password': self.password,
}
else:
# It's not possible to register since we need these 2 values to
# make the action possible.
self.logger.warning(
'Failed to login to Matrix server: '
'user/pass combo is missing.')
'token or user/pass combo is missing.')
return False
# Prepare our Authentication Payload
if self.version == MatrixVersion.V3:
payload = {
'type': 'm.login.password',
'identifier': {
'type': 'm.id.user',
'user': self.user,
},
'password': self.password,
}
else:
payload = {
'type': 'm.login.password',
'user': self.user,
'password': self.password,
}
# Build our URL
postokay, response = self._fetch('/login', payload=payload)
if not (postokay and isinstance(response, dict)):
@ -870,6 +939,18 @@ class NotifyMatrix(NotifyBase):
self.logger.debug(
'Authenticated successfully with Matrix server.')
# Store our token into our store
self.store.set(
'access_token', self.access_token,
expires=self.default_cache_expiry_sec)
self.store.set(
'home_server', self.home_server,
expires=self.default_cache_expiry_sec)
self.store.set(
'user_id', self.user_id,
expires=self.default_cache_expiry_sec)
return True
def _logout(self):
@ -907,8 +988,9 @@ class NotifyMatrix(NotifyBase):
self.home_server = None
self.user_id = None
# Clear our room cache
self._room_cache = {}
# clear our tokens
self.store.clear(
'access_token', 'home_server', 'user_id', 'transaction_id')
self.logger.debug(
'Unauthenticated successfully with Matrix server.')
@ -948,9 +1030,13 @@ class NotifyMatrix(NotifyBase):
)
# Check our cache for speed:
if room_id in self._room_cache:
try:
# We're done as we've already joined the channel
return self._room_cache[room_id]['id']
return self.store[room_id]['id']
except KeyError:
# No worries, we'll try to acquire the info
pass
# Build our URL
path = '/join/{}'.format(NotifyMatrix.quote(room_id))
@ -959,10 +1045,10 @@ class NotifyMatrix(NotifyBase):
postokay, _ = self._fetch(path, payload=payload)
if postokay:
# Cache our entry for fast access later
self._room_cache[room_id] = {
self.store.set(room_id, {
'id': room_id,
'home_server': home_server,
}
})
return room_id if postokay else None
@ -984,9 +1070,13 @@ class NotifyMatrix(NotifyBase):
room = '#{}:{}'.format(result.group('room'), home_server)
# Check our cache for speed:
if room in self._room_cache:
try:
# We're done as we've already joined the channel
return self._room_cache[room]['id']
return self.store[room]['id']
except KeyError:
# No worries, we'll try to acquire the info
pass
# If we reach here, we need to join the channel
@ -997,11 +1087,12 @@ class NotifyMatrix(NotifyBase):
postokay, response = self._fetch(path, payload=payload)
if postokay:
# Cache our entry for fast access later
self._room_cache[room] = {
self.store.set(room, {
'id': response.get('room_id'),
'home_server': home_server,
}
return self._room_cache[room]['id']
})
return response.get('room_id')
# Try to create the channel
return self._room_create(room)
@ -1056,10 +1147,10 @@ class NotifyMatrix(NotifyBase):
return None
# Cache our entry for fast access later
self._room_cache[response.get('room_alias')] = {
self.store.set(response.get('room_alias'), {
'id': response.get('room_id'),
'home_server': home_server,
}
})
return response.get('room_id')
@ -1122,14 +1213,16 @@ class NotifyMatrix(NotifyBase):
return None
def _fetch(self, path, payload=None, params=None, attachment=None,
method='POST'):
def _fetch(self, path, payload=None, params={}, attachment=None,
method='POST', url_override=None):
"""
Wrapper to request.post() to manage it's response better and make
the send() function cleaner and easier to maintain.
This function returns True if the _post was successful and False
if it wasn't.
this function returns the status code if url_override is used
"""
# Define our headers
@ -1142,14 +1235,20 @@ class NotifyMatrix(NotifyBase):
if self.access_token is not None:
headers["Authorization"] = 'Bearer %s' % self.access_token
default_port = 443 if self.secure else 80
# Server Discovery / Well-known URI
if url_override:
url = url_override
url = \
'{schema}://{hostname}{port}'.format(
schema='https' if self.secure else 'http',
hostname=self.host,
port='' if self.port is None
or self.port == default_port else f':{self.port}')
else:
try:
url = self.base_url
except MatrixDiscoveryException:
# Discovery failed; we're done
return (False, {})
# Default return status code
status_code = requests.codes.internal_server_error
if path == '/upload':
# FUTURE if self.version == MatrixVersion.V3:
@ -1159,14 +1258,14 @@ class NotifyMatrix(NotifyBase):
# FUTURE url += MATRIX_V2_MEDIA_PATH + path
url += MATRIX_V2_MEDIA_PATH + path
params = {'filename': attachment.name}
params.update({'filename': attachment.name})
with open(attachment.path, 'rb') as fp:
payload = fp.read()
# Update our content type
headers['Content-Type'] = attachment.mimetype
else:
elif not url_override:
if self.version == MatrixVersion.V3:
url += MATRIX_V3_API_PATH + path
@ -1188,7 +1287,9 @@ class NotifyMatrix(NotifyBase):
# Decrement our throttle retry count
retries -= 1
self.logger.debug('Matrix POST URL: %s (cert_verify=%r)' % (
self.logger.debug('Matrix %s URL: %s (cert_verify=%r)' % (
'POST' if method == 'POST' else (
requests.put if method == 'PUT' else 'GET'),
url, self.verify_certificate,
))
self.logger.debug('Matrix Payload: %s' % str(payload))
@ -1200,18 +1301,21 @@ class NotifyMatrix(NotifyBase):
r = fn(
url,
data=dumps(payload) if not attachment else payload,
params=params,
params=None if not params else params,
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
# Store status code
status_code = r.status_code
self.logger.debug(
'Matrix Response: code=%d, %s' % (
r.status_code, str(r.content)))
response = loads(r.content)
if r.status_code == 429:
if r.status_code == requests.codes.too_many_requests:
wait = self.default_wait_ms / 1000
try:
wait = response['retry_after_ms'] / 1000
@ -1252,7 +1356,8 @@ class NotifyMatrix(NotifyBase):
'Response Details:\r\n{}'.format(r.content))
# Return; we're done
return (False, response)
return (
False if not url_override else status_code, response)
except (AttributeError, TypeError, ValueError):
# This gets thrown if we can't parse our JSON Response
@ -1262,27 +1367,27 @@ class NotifyMatrix(NotifyBase):
self.logger.warning('Invalid response from Matrix server.')
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
return (False, {})
return (False if not url_override else status_code, {})
except requests.RequestException as e:
except (requests.TooManyRedirects, requests.RequestException) as e:
self.logger.warning(
'A Connection error occurred while registering with Matrix'
' server.')
self.logger.debug('Socket Exception: %s' % str(e))
self.logger.debug('Socket Exception: %s', str(e))
# Return; we're done
return (False, response)
return (False if not url_override else status_code, response)
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading {}.'.format(
attachment.name if attachment else 'unknown file'))
self.logger.debug('I/O Exception: %s' % str(e))
return (False, {})
self.logger.debug('I/O Exception: %s', str(e))
return (False if not url_override else status_code, {})
return (True, response)
return (True if not url_override else status_code, response)
# If we get here, we ran out of retries
return (False, {})
return (False if not url_override else status_code, {})
def __del__(self):
"""
@ -1292,6 +1397,15 @@ class NotifyMatrix(NotifyBase):
# nothing to do
return
if self.store.mode != PersistentStoreMode.MEMORY:
# We no longer have to log out as we have persistant storage to
# re-use our credentials with
return
if self.access_token is not None \
and self.access_token == self.password and not self.user:
return
try:
self._logout()
@ -1336,6 +1450,22 @@ class NotifyMatrix(NotifyBase):
# the end user if we don't have to.
pass
@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.host if self.mode != MatrixWebhookMode.T2BOT
else self.access_token,
self.port if self.port else (443 if self.secure else 80),
self.user if self.mode != MatrixWebhookMode.T2BOT else None,
self.password if self.mode != MatrixWebhookMode.T2BOT else None,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -1347,6 +1477,7 @@ class NotifyMatrix(NotifyBase):
'mode': self.mode,
'version': self.version,
'msgtype': self.msgtype,
'discovery': 'yes' if self.discovery else 'no',
}
# Extend our parameters
@ -1363,9 +1494,10 @@ class NotifyMatrix(NotifyBase):
safe=''),
)
elif self.user:
auth = '{user}@'.format(
user=NotifyMatrix.quote(self.user, safe=''),
elif self.user or self.password:
auth = '{value}@'.format(
value=NotifyMatrix.quote(
self.user if self.user else self.password, safe=''),
)
default_port = 443 if self.secure else 80
@ -1416,6 +1548,10 @@ class NotifyMatrix(NotifyBase):
results['include_image'] = parse_bool(results['qsd'].get(
'image', NotifyMatrix.template_args['image']['default']))
# Boolean to perform a server discovery
results['discovery'] = parse_bool(results['qsd'].get(
'discovery', NotifyMatrix.template_args['discovery']['default']))
# Get our mode
results['mode'] = results['qsd'].get('mode')
@ -1443,6 +1579,11 @@ class NotifyMatrix(NotifyBase):
if 'token' in results['qsd'] and len(results['qsd']['token']):
results['password'] = NotifyMatrix.unquote(results['qsd']['token'])
elif not results['password'] and results['user']:
# swap
results['password'] = results['user']
results['user'] = None
# Support the use of the version= or v= keyword
if 'version' in results['qsd'] and len(results['qsd']['version']):
results['version'] = \
@ -1475,3 +1616,200 @@ class NotifyMatrix(NotifyBase):
else '{}&{}'.format(result.group('params'), mode)))
return None
def server_discovery(self):
"""
Home Server Discovery as documented here:
https://spec.matrix.org/v1.11/client-server-api/#well-known-uri
"""
if not (self.discovery and self.secure):
# Nothing further to do with insecure server setups
return ''
# Get our content from cache
base_url, identity_url = (
self.store.get(self.discovery_base_key),
self.store.get(self.discovery_identity_key),
)
if not (base_url is None and identity_url is None):
# We can use our cached value and return early
return base_url
# 1. Extract the server name from the users Matrix ID by splitting
# the Matrix ID at the first colon.
verify_url = f'https://{self.host}/.well-known/matrix/client'
code, wk_response = self._fetch(
None, method='GET', url_override=verify_url)
# Output may look as follows:
# {
# "m.homeserver": {
# "base_url": "https://matrix.example.com"
# },
# "m.identity_server": {
# "base_url": "https://nuxref.com"
# }
# }
if code == requests.codes.not_found:
# This is an acceptable response; we're done
self.logger.debug(
'Matrix Well-Known Base URI not found at %s', verify_url)
# Set our keys out for fast recall later on
self.store.set(
self.discovery_base_key, '',
expires=self.discovery_cache_length_sec)
self.store.set(
self.discovery_identity_key, '',
expires=self.discovery_cache_length_sec)
return ''
elif code != requests.codes.ok:
# We're done early as we couldn't load the results
msg = 'Matrix Well-Known Base URI Discovery Failed'
self.logger.warning(
'%s - %s returned error code: %d', msg, verify_url, code)
raise MatrixDiscoveryException(msg, error_code=code)
if not wk_response:
# This is an acceptable response; we simply do nothing
self.logger.debug(
'Matrix Well-Known Base URI not defined %s', verify_url)
# Set our keys out for fast recall later on
self.store.set(
self.discovery_base_key, '',
expires=self.discovery_cache_length_sec)
self.store.set(
self.discovery_identity_key, '',
expires=self.discovery_cache_length_sec)
return ''
#
# Parse our m.homeserver information
#
try:
base_url = wk_response['m.homeserver']['base_url'].rstrip('/')
results = NotifyBase.parse_url(base_url, verify_host=True)
except (AttributeError, TypeError, KeyError):
# AttributeError: result wasn't a string (rstrip failed)
# TypeError : wk_response wasn't a dictionary
# KeyError : wk_response not to standards
results = None
if not results:
msg = 'Matrix Well-Known Base URI Discovery Failed'
self.logger.warning(
'%s - m.homeserver payload is missing or invalid: %s',
msg, str(wk_response))
raise MatrixDiscoveryException(msg)
#
# Our .well-known extraction was successful; now we need to verify
# that the version information resolves.
#
verify_url = f'{base_url}/_matrix/client/versions'
# Post our content
code, response = self._fetch(
None, method='GET', url_override=verify_url)
if code != requests.codes.ok:
# We're done early as we couldn't load the results
msg = 'Matrix Well-Known Base URI Discovery Verification Failed'
self.logger.warning(
'%s - %s returned error code: %d', msg, verify_url, code)
raise MatrixDiscoveryException(msg, error_code=code)
#
# Phase 2: Handle m.identity_server IF defined
#
if 'm.identity_server' in wk_response:
try:
identity_url = \
wk_response['m.identity_server']['base_url'].rstrip('/')
results = NotifyBase.parse_url(identity_url, verify_host=True)
except (AttributeError, TypeError, KeyError):
# AttributeError: result wasn't a string (rstrip failed)
# TypeError : wk_response wasn't a dictionary
# KeyError : wk_response not to standards
results = None
if not results:
msg = 'Matrix Well-Known Identity URI Discovery Failed'
self.logger.warning(
'%s - m.identity_server payload is missing or invalid: %s',
msg, str(wk_response))
raise MatrixDiscoveryException(msg)
#
# Verify identity server found
#
verify_url = f'{identity_url}/_matrix/identity/v2'
# Post our content
code, response = self._fetch(
None, method='GET', url_override=verify_url)
if code != requests.codes.ok:
# We're done early as we couldn't load the results
msg = 'Matrix Well-Known Identity URI Discovery Failed'
self.logger.warning(
'%s - %s returned error code: %d', msg, verify_url, code)
raise MatrixDiscoveryException(msg, error_code=code)
# Update our cache
self.store.set(
self.discovery_identity_key, identity_url,
# Add 2 seconds to prevent this key from expiring before base
expires=self.discovery_cache_length_sec + 2)
else:
# No identity server
self.store.set(
self.discovery_identity_key, '',
# Add 2 seconds to prevent this key from expiring before base
expires=self.discovery_cache_length_sec + 2)
# Update our cache
self.store.set(
self.discovery_base_key, base_url,
expires=self.discovery_cache_length_sec)
return base_url
@property
def base_url(self):
"""
Returns the base_url if known
"""
try:
base_url = self.server_discovery()
if base_url:
# We can use our cached value and return early
return base_url
except MatrixDiscoveryException:
self.store.clear(
self.discovery_base_key, self.discovery_identity_key)
raise
# If we get hear, we need to build our URL dynamically based on what
# was provided to us during the plugins initialization
default_port = 443 if self.secure else 80
return '{schema}://{hostname}{port}'.format(
schema='https' if self.secure else 'http',
hostname=self.host,
port='' if self.port is None
or self.port == default_port else f':{self.port}')
@property
def identity_url(self):
"""
Returns the identity_url if known
"""
base_url = self.base_url
identity_url = self.store.get(self.discovery_identity_key)
return base_url if not identity_url else identity_url

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,15 +36,14 @@
# - swap http with mmost
# - drop /hooks/ reference
import re
import requests
from json import dumps
from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_bool
from ..utils import parse_list
from ..utils import validate_regex
from ..utils.parse import parse_bool, parse_list, validate_regex
from ..locale import gettext_lazy as _
# Some Reference Locations:
@ -72,9 +71,6 @@ class NotifyMattermost(NotifyBase):
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mattermost'
# The default Mattermost port
default_port = 8065
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_72
@ -132,6 +128,9 @@ class NotifyMattermost(NotifyBase):
'name': _('Channels'),
'type': 'list:string',
},
'channel': {
'alias_of': 'channels',
},
'image': {
'name': _('Include Image'),
'type': 'bool',
@ -171,9 +170,6 @@ class NotifyMattermost(NotifyBase):
# Optional Channels (strip off any channel prefix entries if present)
self.channels = [x.lstrip('#') for x in parse_list(channels)]
if not self.port:
self.port = self.default_port
# Place a thumbnail image inline with the message body
self.include_image = include_image
@ -220,8 +216,8 @@ class NotifyMattermost(NotifyBase):
payload['channel'] = channel
url = '{}://{}:{}{}/hooks/{}'.format(
self.schema, self.host, self.port, self.fullpath,
self.token)
self.schema, self.host, self.port,
self.fullpath.rstrip('/'), self.token)
self.logger.debug('Mattermost POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate,
@ -283,6 +279,18 @@ class NotifyMattermost(NotifyBase):
# Return our overall status
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.token, self.host, self.port, self.fullpath,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -303,7 +311,7 @@ class NotifyMattermost(NotifyBase):
params['channel'] = ','.join(
[NotifyMattermost.quote(x, safe='') for x in self.channels])
default_port = 443 if self.secure else self.default_port
default_port = 443 if self.secure else 80
default_schema = self.secure_protocol if self.secure else self.protocol
# Determine if there is a botname present
@ -321,8 +329,8 @@ class NotifyMattermost(NotifyBase):
# never encode hostname since we're expecting it to be a valid
# one
hostname=self.host,
port='' if not self.port or self.port == default_port
else ':{}'.format(self.port),
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
fullpath='/' if not self.fullpath else '{}/'.format(
NotifyMattermost.quote(self.fullpath, safe='/')),
token=self.pprint(self.token, privacy, safe=''),
@ -357,16 +365,60 @@ class NotifyMattermost(NotifyBase):
# Support both 'to' (for yaml configuration) and channel=
if 'to' in results['qsd'] and len(results['qsd']['to']):
# Allow the user to specify the channel to post to
results['channels'].append(
results['channels'].extend(
NotifyMattermost.parse_list(results['qsd']['to']))
if 'channel' in results['qsd'] and len(results['qsd']['channel']):
# Allow the user to specify the channel to post to
results['channels'].append(
results['channels'].extend(
NotifyMattermost.parse_list(results['qsd']['channel']))
if 'channels' in results['qsd'] and len(results['qsd']['channels']):
# Allow the user to specify the channel to post to
results['channels'].extend(
NotifyMattermost.parse_list(results['qsd']['channels']))
# Image manipulation
results['include_image'] = \
parse_bool(results['qsd'].get('image', False))
results['include_image'] = parse_bool(results['qsd'].get(
'image', NotifyMattermost.template_args['image']['default']))
return results
@staticmethod
def parse_native_url(url):
"""
Support parsing the webhook straight from URL
https://HOST:443/workflows/WORKFLOWID/triggers/manual/paths/invoke
https://mattermost.HOST/hooks/TOKEN
"""
# Match our workflows webhook URL and re-assemble
result = re.match(
r'^http(?P<secure>s?)://(?P<host>mattermost\.[A-Z0-9_.-]+)'
r'(:(?P<port>[1-9][0-9]{0,5}))?'
r'/hooks/'
r'(?P<token>[A-Z0-9_-]+)/?'
r'(?P<params>\?.+)?$', url, re.I)
if result:
default_port = \
int(result.group('port')) if result.group('port') else (
443 if result.group('secure') else 80)
default_schema = \
NotifyMattermost.secure_protocol \
if result.group('secure') else NotifyMattermost.protocol
# Construct our URL
return NotifyMattermost.parse_url(
'{schema}://{host}{port}/{token}'
'/{params}'.format(
schema=default_schema,
host=result.group('host'),
port='' if not result.group('port')
or int(result.group('port')) == default_port
else f':{default_port}',
token=result.group('token'),
params='' if not result.group('params')
else result.group('params')))
return None

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -36,9 +36,7 @@ import requests
from .base import NotifyBase
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import validate_regex
from ..utils.parse import is_phone_no, parse_phone_no, validate_regex
from ..locale import gettext_lazy as _
@ -291,6 +289,15 @@ class NotifyMessageBird(NotifyBase):
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.apikey, self.source)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -49,7 +49,7 @@ from json import dumps
from .base import NotifyBase
from ..common import NotifyType
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
@ -64,8 +64,6 @@ class MisskeyVisibility:
FOLLOWERS = 'followers'
PRIVATE = 'private'
SPECIFIED = 'specified'
@ -74,7 +72,6 @@ MISSKEY_VISIBILITIES = (
MisskeyVisibility.PUBLIC,
MisskeyVisibility.HOME,
MisskeyVisibility.FOLLOWERS,
MisskeyVisibility.PRIVATE,
MisskeyVisibility.SPECIFIED,
)
@ -191,6 +188,18 @@ class NotifyMisskey(NotifyBase):
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 if self.secure else self.protocol,
self.token, self.host, self.port,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -41,8 +41,7 @@ from os.path import isfile
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import parse_list
from ..utils import parse_bool
from ..utils.parse import parse_list, parse_bool
from ..locale import gettext_lazy as _
# Default our global support flag
@ -89,7 +88,7 @@ class NotifyMQTT(NotifyBase):
requirements = {
# Define our required packaging in order to work
'packages_required': 'paho-mqtt < 2.0.0'
'packages_required': 'paho-mqtt != 2.0.*'
}
# The default descriptive name associated with the Notification
@ -204,10 +203,15 @@ class NotifyMQTT(NotifyBase):
'type': 'bool',
'default': False,
},
'retain': {
'name': _('Retain Messages'),
'type': 'bool',
'default': False,
},
})
def __init__(self, targets=None, version=None, qos=None,
client_id=None, session=None, **kwargs):
client_id=None, session=None, retain=None, **kwargs):
"""
Initialize MQTT Object
"""
@ -230,6 +234,10 @@ class NotifyMQTT(NotifyBase):
if session is None or not self.client_id \
else parse_bool(session)
# Our Retain Message Flag
self.retain = self.template_args['retain']['default'] \
if retain is None else parse_bool(retain)
# Set up our Quality of Service (QoS)
try:
self.qos = self.template_args['qos']['default'] \
@ -376,7 +384,7 @@ class NotifyMQTT(NotifyBase):
self.logger.debug('MQTT Payload: %s' % str(body))
result = self.client.publish(
topic, payload=body, qos=self.qos, retain=False)
topic, payload=body, qos=self.qos, retain=self.retain)
if result.rc != mqtt.MQTT_ERR_SUCCESS:
# Toggle our status
@ -429,6 +437,23 @@ class NotifyMQTT(NotifyBase):
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 if self.port else (
self.mqtt_secure_port if self.secure
else self.mqtt_insecure_port),
self.fullpath.rstrip('/'),
self.client_id,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -439,6 +464,7 @@ class NotifyMQTT(NotifyBase):
'version': self.version,
'qos': str(self.qos),
'session': 'yes' if self.session else 'no',
'retain': 'yes' if self.retain else 'no',
}
if self.client_id:
@ -518,6 +544,10 @@ class NotifyMQTT(NotifyBase):
if 'session' in results['qsd'] and len(results['qsd']['session']):
results['session'] = parse_bool(results['qsd']['session'])
# Message Retain Flag
if 'retain' in results['qsd'] and len(results['qsd']['retain']):
results['retain'] = parse_bool(results['qsd']['retain'])
# The MQTT Quality of Service to use
if 'qos' in results['qsd'] and len(results['qsd']['qos']):
results['qos'] = \

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -40,9 +40,8 @@ import requests
from json import dumps
from .base import NotifyBase
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no, parse_bool
from ..utils import validate_regex
from ..utils.parse import (
is_phone_no, parse_phone_no, parse_bool, validate_regex)
from ..locale import gettext_lazy as _
@ -310,6 +309,15 @@ class NotifyMSG91(NotifyBase):
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, self.template, self.authkey)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -85,10 +85,8 @@ from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..common import NotifyFormat
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils import apply_template
from ..utils import TemplateType
from ..utils.parse import parse_bool, validate_regex
from ..utils.templates import apply_template, TemplateType
from ..apprise_attachment import AppriseAttachment
from ..locale import gettext_lazy as _
@ -118,6 +116,9 @@ class NotifyMSTeams(NotifyBase):
notify_url_v2 = 'https://{team}.webhook.office.com/webhookb2/' \
'{token_a}/IncomingWebhook/{token_b}/{token_c}'
notify_url_v3 = 'https://{team}.webhook.office.com/webhookb2/' \
'{token_a}/IncomingWebhook/{token_b}/{token_c}/{token_d}'
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_72
@ -176,6 +177,15 @@ class NotifyMSTeams(NotifyBase):
'required': True,
'regex': (r'^[a-z0-9-]+$', 'i'),
},
# Token required as part of the API request
# /........./........./........./DDDDDDDDDDDDDDDDD
'token_d': {
'name': _('Token D'),
'type': 'string',
'private': True,
'required': False,
'regex': (r'^V2[a-zA-Z0-9-_]+$', 'i'),
},
})
# Define our template arguments
@ -189,7 +199,7 @@ class NotifyMSTeams(NotifyBase):
'version': {
'name': _('Version'),
'type': 'choice:int',
'values': (1, 2),
'values': (1, 2, 3),
'default': 2,
},
'template': {
@ -207,8 +217,9 @@ class NotifyMSTeams(NotifyBase):
},
}
def __init__(self, token_a, token_b, token_c, team=None, version=None,
include_image=True, template=None, tokens=None, **kwargs):
def __init__(self, token_a, token_b, token_c, token_d=None, team=None,
version=None, include_image=True, template=None, tokens=None,
**kwargs):
"""
Initialize Microsoft Teams Object
@ -271,6 +282,9 @@ class NotifyMSTeams(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
self.token_d = validate_regex(
token_d, *self.template_tokens['token_d']['regex'])
# Place a thumbnail image inline with the message body
self.include_image = include_image
@ -293,8 +307,12 @@ class NotifyMSTeams(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
# else: NoneType - this is okay
return
self.logger.deprecate(
"Microsoft is deprecating their MSTeams webhooks on "
"December 31, 2025. It is advised that you switch to "
"Microsoft Power Automate (already supported by Apprise as "
"workflows://. For more information visit: "
"https://github.com/caronc/apprise/wiki/Notify_workflows")
def gen_payload(self, body, title='', notify_type=NotifyType.INFO,
**kwargs):
@ -398,17 +416,28 @@ class NotifyMSTeams(NotifyBase):
'Content-Type': 'application/json',
}
notify_url = self.notify_url_v2.format(
team=self.team,
token_a=self.token_a,
token_b=self.token_b,
token_c=self.token_c,
) if self.version > 1 else \
self.notify_url_v1.format(
if self.version == 1:
notify_url = self.notify_url_v1.format(
token_a=self.token_a,
token_b=self.token_b,
token_c=self.token_c)
if self.version == 2:
notify_url = self.notify_url_v2.format(
team=self.team,
token_a=self.token_a,
token_b=self.token_b,
token_c=self.token_c,
)
if self.version == 3:
notify_url = self.notify_url_v3.format(
team=self.team,
token_a=self.token_a,
token_b=self.token_b,
token_c=self.token_c,
token_d=self.token_d,
)
# Generate our payload if it's possible
payload = self.gen_payload(
body=body, title=title, notify_type=notify_type, **kwargs)
@ -463,6 +492,19 @@ class NotifyMSTeams(NotifyBase):
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,
self.team if self.version > 1 else None,
self.token_a, self.token_b, self.token_c,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -485,8 +527,20 @@ class NotifyMSTeams(NotifyBase):
# Store any template entries if specified
params.update({':{}'.format(k): v for k, v in self.tokens.items()})
if self.version > 1:
return '{schema}://{team}/{token_a}/{token_b}/{token_c}/'\
result = None
if self.version == 1:
result = '{schema}://{token_a}/{token_b}/{token_c}/'\
'?{params}'.format(
schema=self.secure_protocol,
token_a=self.pprint(self.token_a, privacy, safe='@'),
token_b=self.pprint(self.token_b, privacy, safe=''),
token_c=self.pprint(self.token_c, privacy, safe=''),
params=NotifyMSTeams.urlencode(params),
)
if self.version == 2:
result = '{schema}://{team}/{token_a}/{token_b}/{token_c}/'\
'?{params}'.format(
schema=self.secure_protocol,
team=NotifyMSTeams.quote(self.team, safe=''),
@ -496,15 +550,18 @@ class NotifyMSTeams(NotifyBase):
params=NotifyMSTeams.urlencode(params),
)
else: # Version 1
return '{schema}://{token_a}/{token_b}/{token_c}/'\
'?{params}'.format(
if self.version == 3:
result = '{schema}://{team}/{token_a}/{token_b}/{token_c}/'\
'{token_d}/?{params}'.format(
schema=self.secure_protocol,
token_a=self.pprint(self.token_a, privacy, safe='@'),
team=NotifyMSTeams.quote(self.team, safe=''),
token_a=self.pprint(self.token_a, privacy, safe=''),
token_b=self.pprint(self.token_b, privacy, safe=''),
token_c=self.pprint(self.token_c, privacy, safe=''),
token_d=self.pprint(self.token_d, privacy, safe=''),
params=NotifyMSTeams.urlencode(params),
)
return result
@staticmethod
def parse_url(url):
@ -543,6 +600,8 @@ class NotifyMSTeams(NotifyBase):
else NotifyMSTeams.unquote(entries.pop(0))
results['token_c'] = None if not entries \
else NotifyMSTeams.unquote(entries.pop(0))
results['token_d'] = None if not entries \
else NotifyMSTeams.unquote(entries.pop(0))
# Get Image
results['include_image'] = \
@ -564,8 +623,13 @@ class NotifyMSTeams(NotifyBase):
NotifyMSTeams.unquote(results['qsd']['version'])
else:
version = 1
if results.get('team'):
version = 2
if results.get('token_d'):
version = 3
# Set our version if not otherwise set
results['version'] = 1 if not results.get('team') else 2
results['version'] = version
# Store our tokens
results['tokens'] = results['qsd:']
@ -580,11 +644,38 @@ class NotifyMSTeams(NotifyBase):
New Hook Support:
https://team-name.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK
Newer Hook Support:
https://team-name.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK/V2LMNOP
"""
# We don't need to do incredibly details token matching as the purpose
# of this is just to detect that were dealing with an msteams url
# token parsing will occur once we initialize the function
result = re.match(
r'^https?://(?P<team>[^.]+)(?P<v2a>\.webhook)?\.office\.com/'
r'webhook(?P<v2b>b2)?/'
r'(?P<token_a>[A-Z0-9-]+@[A-Z0-9-]+)/'
r'IncomingWebhook/'
r'(?P<token_b>[A-Z0-9]+)/'
r'(?P<token_c>[A-Z0-9-]+)/'
r'(?P<token_d>V2[A-Z0-9-_]+)/?'
r'(?P<params>\?.+)?$', url, re.I)
if result:
# Version 3 URL
return NotifyMSTeams.parse_url(
'{schema}://{team}/{token_a}/{token_b}/{token_c}/{token_d}'
'/{params}'.format(
schema=NotifyMSTeams.secure_protocol,
team=result.group('team'),
token_a=result.group('token_a'),
token_b=result.group('token_b'),
token_c=result.group('token_c'),
token_d=result.group('token_d'),
params='' if not result.group('params')
else result.group('params')))
result = re.match(
r'^https?://(?P<team>[^.]+)(?P<v2a>\.webhook)?\.office\.com/'
r'webhook(?P<v2b>b2)?/'

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -31,7 +31,7 @@ import requests
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import parse_list
from ..utils.parse import parse_list
from ..locale import gettext_lazy as _
@ -278,6 +278,18 @@ class NotifyNextcloud(NotifyBase):
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.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -32,7 +32,7 @@ from json import dumps
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import parse_list
from ..utils.parse import parse_list
from ..locale import gettext_lazy as _
@ -253,6 +253,18 @@ class NotifyNextcloudTalk(NotifyBase):
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.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -46,7 +46,7 @@ import requests
from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..utils import validate_regex
from ..utils.parse import validate_regex
from ..locale import gettext_lazy as _
@ -278,6 +278,19 @@ class NotifyNotica(NotifyBase):
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.mode, self.token, self.user, self.password, self.host,
self.port,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -35,8 +35,8 @@ from .base import NotifyBase
from ..common import NotifyType
from ..locale import gettext_lazy as _
from ..common import NotifyImageSize
from ..utils import parse_list, parse_bool
from ..utils import validate_regex
from ..utils.parse import parse_list, parse_bool, validate_regex
from .discord import USER_ROLE_DETECTION_RE
# Used to break path apart into list of channels
CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+')
@ -118,14 +118,6 @@ class NotifyNotifiarr(NotifyBase):
'apikey': {
'alias_of': 'apikey',
},
'discord_user': {
'name': _('Ping Discord User'),
'type': 'int',
},
'discord_role': {
'name': _('Ping Discord Role'),
'type': 'int',
},
'event': {
'name': _('Discord Event ID'),
'type': 'int',
@ -149,7 +141,6 @@ class NotifyNotifiarr(NotifyBase):
})
def __init__(self, apikey=None, include_image=None,
discord_user=None, discord_role=None,
event=None, targets=None, source=None, **kwargs):
"""
Initialize Notifiarr Object
@ -172,30 +163,6 @@ class NotifyNotifiarr(NotifyBase):
if isinstance(include_image, bool) \
else self.template_args['image']['default']
# Set up our user if specified
self.discord_user = 0
if discord_user:
try:
self.discord_user = int(discord_user)
except (ValueError, TypeError):
msg = 'An invalid Notifiarr User ID ' \
'({}) was specified.'.format(discord_user)
self.logger.warning(msg)
raise TypeError(msg)
# Set up our role if specified
self.discord_role = 0
if discord_role:
try:
self.discord_role = int(discord_role)
except (ValueError, TypeError):
msg = 'An invalid Notifiarr Role ID ' \
'({}) was specified.'.format(discord_role)
self.logger.warning(msg)
raise TypeError(msg)
# Prepare our source (if set)
self.source = validate_regex(source)
@ -231,6 +198,18 @@ class NotifyNotifiarr(NotifyBase):
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 if self.secure else self.protocol,
self.apikey,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -244,12 +223,6 @@ class NotifyNotifiarr(NotifyBase):
if self.source:
params['source'] = self.source
if self.discord_user:
params['discord_user'] = self.discord_user
if self.discord_role:
params['discord_role'] = self.discord_role
if self.event:
params['event'] = self.event
@ -287,6 +260,29 @@ class NotifyNotifiarr(NotifyBase):
# Acquire image_url
image_url = self.image_url(notify_type)
# Define our mentions
mentions = {
'pingUser': [],
'pingRole': [],
'content': [],
}
# parse for user id's <@123> and role IDs <@&456>
results = USER_ROLE_DETECTION_RE.findall(body)
if results:
for (is_role, no, value) in results:
if value:
# @everybody, @admin, etc - unsupported
mentions['content'].append(f'@{value}')
elif is_role:
mentions['pingRole'].append(no)
mentions['content'].append(f'<@&{no}>')
else: # is_user
mentions['pingUser'].append(no)
mentions['content'].append(f'<@{no}>')
for idx, channel in enumerate(self.targets['channels']):
# prepare Notifiarr Object
payload = {
@ -301,14 +297,17 @@ class NotifyNotifiarr(NotifyBase):
'discord': {
'color': self.color(notify_type),
'ping': {
'pingUser': self.discord_user
if not idx and self.discord_user else 0,
'pingRole': self.discord_role
if not idx and self.discord_role else 0,
# Only 1 user is supported, so truncate the rest
'pingUser': 0 if not mentions['pingUser']
else mentions['pingUser'][0],
# Only 1 role is supported, so truncate the rest
'pingRole': 0 if not mentions['pingRole']
else mentions['pingRole'][0],
},
'text': {
'title': title,
'content': '',
'content': '' if not mentions['content']
else '👉 ' + ' '.join(mentions['content']),
'description': body,
'footer': self.app_desc,
},
@ -410,17 +409,6 @@ class NotifyNotifiarr(NotifyBase):
# Get channels
results['targets'] = NotifyNotifiarr.split_path(results['fullpath'])
if 'discord_user' in results['qsd'] and \
len(results['qsd']['discord_user']):
results['discord_user'] = \
NotifyNotifiarr.unquote(
results['qsd']['discord_user'])
if 'discord_role' in results['qsd'] and \
len(results['qsd']['discord_role']):
results['discord_role'] = \
NotifyNotifiarr.unquote(results['qsd']['discord_role'])
if 'event' in results['qsd'] and \
len(results['qsd']['event']):
results['event'] = \

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -46,8 +46,7 @@ import requests
from .base import NotifyBase
from ..common import NotifyType
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils.parse import parse_bool, validate_regex
from ..locale import gettext_lazy as _
@ -197,6 +196,15 @@ class NotifyNotifico(NotifyBase):
)
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.project_id, self.msghook)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -46,11 +46,8 @@ from ..common import NotifyFormat
from ..common import NotifyType
from ..common import NotifyImageSize
from ..locale import gettext_lazy as _
from ..utils import parse_list
from ..utils import parse_bool
from ..utils import is_hostname
from ..utils import is_ipaddr
from ..utils import validate_regex
from ..utils.parse import (
parse_list, parse_bool, is_hostname, is_ipaddr, validate_regex)
from ..url import PrivacyMode
from ..attachment.base import AttachBase
from ..attachment.memory import AttachMemory
@ -656,6 +653,34 @@ class NotifyNtfy(NotifyBase):
return False, response
@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.
"""
kwargs = [
self.secure_protocol if self.mode == NtfyMode.CLOUD else (
self.secure_protocol if self.secure else self.protocol),
self.host if self.mode == NtfyMode.PRIVATE else '',
443 if self.mode == NtfyMode.CLOUD else (
self.port if self.port else (443 if self.secure else 80)),
]
if self.mode == NtfyMode.PRIVATE:
if self.auth == NtfyAuth.BASIC:
kwargs.extend([
self.user if self.user else None,
self.password if self.password else None,
])
elif self.token: # NtfyAuth.TOKEN also
kwargs.append(self.token)
return kwargs
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -33,45 +33,25 @@
# Information on sending an email:
# https://docs.microsoft.com/en-us/graph/api/user-sendmail\
# ?view=graph-rest-1.0&tabs=http
# Steps to get your Microsoft Client ID, Client Secret, and Tenant ID:
# 1. You should have valid Microsoft personal account. Go to Azure Portal
# 2. Go to -> Microsoft Active Directory --> App Registrations
# 3. Click new -> give any name (your choice) in Name field -> select
# personal Microsoft accounts only --> Register
# 4. Now you have your client_id & Tenant id.
# 5. To create client_secret , go to active directory ->
# Certificate & Tokens -> New client secret
# **This is auto-generated string which may have '@' and '?'
# characters in it. You should encode these to prevent
# from having any issues.**
# 6. Now need to set permission Active directory -> API permissions ->
# Add permission (search mail) , add relevant permission.
# 7. Set the redirect uri (Web) to:
# https://login.microsoftonline.com/common/oauth2/nativeclient
#
# ...and click register.
# Note: One must set up Application Permissions (not Delegated Permissions)
# - Scopes required: Mail.Send
# - For Large Attachments: Mail.ReadWrite
# - For Email Lookups: User.Read.All
#
# This needs to be inserted into the "Redirect URI" text box as simply
# checking the check box next to this link seems to be insufficient.
# This is the default redirect uri used by this library, but you can use
# any other if you want.
#
# 8. Now you're good to go
import requests
import json
from uuid import uuid4
from datetime import datetime
from datetime import timedelta
from json import loads
from json import dumps
from .base import NotifyBase
from .. import exception
from ..url import PrivacyMode
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import is_email
from ..utils import parse_emails
from ..utils import validate_regex
from ..utils.parse import is_email, parse_emails, validate_regex
from ..locale import gettext_lazy as _
from ..common import PersistentStoreMode
class NotifyOffice365(NotifyBase):
@ -86,7 +66,7 @@ class NotifyOffice365(NotifyBase):
service_url = 'https://office.com/'
# The default protocol
secure_protocol = 'o365'
secure_protocol = ('azure', 'o365')
# Allow 300 requests per minute.
# 60/300 = 0.2
@ -101,6 +81,20 @@ class NotifyOffice365(NotifyBase):
# Authentication URL
auth_url = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token'
# Support attachments
attachment_support = True
# Our default is to no not use persistent storage beyond in-memory
# reference
storage_mode = PersistentStoreMode.AUTO
# the maximum size an attachment can be for it to be allowed to be
# uploaded inline with the current email going out (one http post)
# Anything larger than this and a second PUT request is required to
# the outlook server to post the content through reference.
# Currently (as of 2025.10.06) this was documented to be 3MB
outlook_attachment_inline_max = 3145728
# Use all the direct application permissions you have configured for your
# app. The endpoint should issue a token for the ones associated with the
# resource you want to use.
@ -113,8 +107,9 @@ class NotifyOffice365(NotifyBase):
# Define object templates
templates = (
'{schema}://{tenant}:{email}/{client_id}/{secret}',
'{schema}://{tenant}:{email}/{client_id}/{secret}/{targets}',
# Send as user (only supported method)
'{schema}://{source}/{tenant}/{client_id}/{secret}',
'{schema}://{source}/{tenant}/{client_id}/{secret}/{targets}',
)
# Define our template tokens
@ -126,8 +121,8 @@ class NotifyOffice365(NotifyBase):
'private': True,
'regex': (r'^[a-z0-9-]+$', 'i'),
},
'email': {
'name': _('Account Email'),
'source': {
'name': _('Account Email or Object ID'),
'type': 'string',
'required': True,
},
@ -176,7 +171,7 @@ class NotifyOffice365(NotifyBase):
},
})
def __init__(self, tenant, email, client_id, secret,
def __init__(self, tenant, client_id, secret, source=None,
targets=None, cc=None, bcc=None, **kwargs):
"""
Initialize Office 365 Object
@ -192,15 +187,8 @@ class NotifyOffice365(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
result = is_email(email)
if not result:
msg = 'An invalid Office 365 Email Account ID' \
'({}) was specified.'.format(email)
self.logger.warning(msg)
raise TypeError(msg)
# Otherwise store our the email address
self.email = result['full_email']
# Store our email/ObjectID Source
self.source = source
# Client Key (associated with generated OAuth2 Login)
self.client_id = validate_regex(
@ -247,8 +235,14 @@ class NotifyOffice365(NotifyBase):
.format(recipient))
else:
# If our target email list is empty we want to add ourselves to it
self.targets.append((False, self.email))
result = is_email(self.source)
if not result:
self.logger.warning('No Target Office 365 Email Detected')
else:
# If our target email list is empty we want to add ourselves to
# it
self.targets.append((False, self.source))
# Validate recipients (cc:) and drop bad ones:
for recipient in parse_emails(cc):
@ -288,9 +282,23 @@ class NotifyOffice365(NotifyBase):
# Presume that our token has expired 'now'
self.token_expiry = datetime.now()
# Our email source; we detect this if the source is an ObjectID
# If it is unknown we set this to None
# User is the email associated with the account
self.from_email = self.store.get('from')
result = is_email(self.source)
if result:
self.from_email = result['full_email']
self.from_name = \
result['name'] or self.store.get('name')
else:
self.from_name = self.store.get('name')
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
Perform Office 365 Notification
"""
@ -304,31 +312,143 @@ class NotifyOffice365(NotifyBase):
'There are no Email recipients to notify')
return False
if self.from_email is None:
if not self.authenticate():
# We could not authenticate ourselves; we're done
return False
# Acquire our from_email
url = "https://graph.microsoft.com/v1.0/users/{}".format(
self.source)
postokay, response = self._fetch(url=url, method='GET')
if not postokay:
self.logger.warning(
'Could not acquire From email address; ensure '
'"User.Read.All" Application scope is set!')
else: # Acquire our from_email (if possible)
from_email = \
response.get("mail") or response.get("userPrincipalName")
result = is_email(from_email)
if not result:
self.logger.warning(
'Could not get From email from the Azure endpoint.')
# Prevent re-occuring upstream fetches for info that isn't
# there
self.from_email = False
else:
# Store our email for future reference
self.from_email = result['full_email']
self.store.set('from', result['full_email'])
self.from_name = response.get("displayName")
if self.from_name:
self.store.set('name', self.from_name)
# Setup our Content Type
content_type = \
'HTML' if self.notify_format == NotifyFormat.HTML else 'Text'
# Prepare our payload
payload = {
'Message': {
'Subject': title,
'Body': {
'ContentType': content_type,
'Content': body,
'message': {
'subject': title,
'body': {
'contentType': content_type,
'content': body,
},
},
'SaveToSentItems': 'false'
# Below takes a string (not bool) of either 'true' or 'false'
'saveToSentItems': 'true'
}
if self.from_email:
# Apply from email if it is known
payload.update({
'message': {
'from': {
"emailAddress": {
"address": self.from_email,
"name": self.from_name or self.app_id,
}
},
}
})
# Create a copy of the email list
emails = list(self.targets)
# Define our URL to post to
url = '{graph_url}/v1.0/users/{email}/sendmail'.format(
email=self.email,
url = '{graph_url}/v1.0/users/{userid}/sendMail'.format(
graph_url=self.graph_url,
userid=self.source,
)
# Prepare our Draft URL
draft_url = \
'{graph_url}/v1.0/users/{userid}/messages' \
.format(
graph_url=self.graph_url,
userid=self.source,
)
small_attachments = []
large_attachments = []
# draft emails
drafts = []
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 Office 365 attachment {}.'.format(
attachment.url(privacy=True)))
return False
if len(attachment) > self.outlook_attachment_inline_max:
# Messages larger then xMB need to be uploaded after; a
# draft email must be prepared; below is our session
large_attachments.append({
'obj': attachment,
'name': attachment.name
if attachment.name else f'file{no:03}.dat',
})
continue
try:
# Prepare our Attachment in Base64
small_attachments.append({
"@odata.type": "#microsoft.graph.fileAttachment",
# Name of the attachment (as it should appear in email)
"name": attachment.name
if attachment.name else f'file{no:03}.dat',
# MIME type of the attachment
"contentType": "attachment.mimetype",
# Base64 Content
"contentBytes": attachment.base64(),
})
except exception.AppriseException:
# We could not access the attachment
self.logger.error(
'Could not access Office 365 attachment {}.'.format(
attachment.url(privacy=True)))
return False
self.logger.debug(
'Appending Office 365 attachment {}'.format(
attachment.url(privacy=True)))
if small_attachments:
# Store Attachments
payload['message']['attachments'] = small_attachments
while len(emails):
# authenticate ourselves if we aren't already; but this function
# also tracks if our token we have is still valid and will
@ -347,63 +467,197 @@ class NotifyOffice365(NotifyBase):
bcc = (self.bcc - set([to_addr]))
# Prepare our email
payload['Message']['ToRecipients'] = [{
'EmailAddress': {
'Address': to_addr
payload['message']['toRecipients'] = [{
'emailAddress': {
'address': to_addr
}
}]
if to_name:
# Apply our To Name
payload['Message']['ToRecipients'][0]['EmailAddress']['Name'] \
payload['message']['toRecipients'][0]['emailAddress']['name'] \
= to_name
self.logger.debug('Email To: {}'.format(to_addr))
self.logger.debug('{}Email To: {}'.format(
'Draft' if large_attachments else '', to_addr))
if cc:
# Prepare our CC list
payload['Message']['CcRecipients'] = []
payload['message']['ccRecipients'] = []
for addr in cc:
_payload = {'Address': addr}
_payload = {'address': addr}
if self.names.get(addr):
_payload['Name'] = self.names[addr]
_payload['name'] = self.names[addr]
# Store our address in our payload
payload['Message']['CcRecipients']\
.append({'EmailAddress': _payload})
payload['message']['ccRecipients']\
.append({'emailAddress': _payload})
self.logger.debug('Email Cc: {}'.format(', '.join(
['{}{}'.format(
'' if self.names.get(e)
else '{}: '.format(self.names[e]), e) for e in cc])))
self.logger.debug('{}Email Cc: {}'.format(
'Draft' if large_attachments else '', ', '.join(
['{}{}'.format(
'' if self.names.get(e)
else '{}: '.format(
self.names[e]), e) for e in cc])))
if bcc:
# Prepare our CC list
payload['Message']['BccRecipients'] = []
payload['message']['bccRecipients'] = []
for addr in bcc:
_payload = {'Address': addr}
_payload = {'address': addr}
if self.names.get(addr):
_payload['Name'] = self.names[addr]
_payload['name'] = self.names[addr]
# Store our address in our payload
payload['Message']['BccRecipients']\
.append({'EmailAddress': _payload})
payload['message']['bccRecipients']\
.append({'emailAddress': _payload})
self.logger.debug('Email Bcc: {}'.format(', '.join(
['{}{}'.format(
'' if self.names.get(e)
else '{}: '.format(self.names[e]), e) for e in bcc])))
self.logger.debug('{}Email Bcc: {}'.format(
'Draft' if large_attachments else '', ', '.join(
['{}{}'.format(
'' if self.names.get(e)
else '{}: '.format(
self.names[e]), e) for e in bcc])))
# Perform upstream fetch
# Perform upstream post
postokay, response = self._fetch(
url=url, payload=dumps(payload),
content_type='application/json')
url=url if not large_attachments
else draft_url, payload=payload)
# Test if we were okay
if not postokay:
has_error = True
elif large_attachments:
# We have large attachments now to upload and associate with
# our message. We need to prepare a draft message; acquire
# the message-id associated with it and then attach the file
# via this means.
# Acquire our Draft ID to work with
message_id = response.get("id")
if not message_id:
self.logger.warning(
'Email Draft ID could not be retrieved')
has_error = True
continue
self.logger.debug('Email Draft ID: {}'.format(message_id))
# In future, the below could probably be called via async
has_attach_error = False
for attachment in large_attachments:
if not self.upload_attachment(
attachment['obj'], message_id, attachment['name']):
self.logger.warning(
'Could not prepare attachment session for %s',
attachment['name'])
has_error = True
has_attach_error = True
# Take early exit
break
if has_attach_error:
continue
# Send off our draft
attach_url = \
"https://graph.microsoft.com/v1.0/users/" \
"{}/messages/{}/send"
attach_url = attach_url.format(
self.source,
message_id,
)
# Trigger our send
postokay, response = self._fetch(url=url)
if not postokay:
self.logger.warning(
'Could not send drafted email id: {} ', message_id)
has_error = True
continue
# Memory management
del small_attachments
del large_attachments
del drafts
return not has_error
def upload_attachment(self, attachment, message_id, name=None):
"""
Uploads an attachment to a session
"""
# Perform some simple error checking
if not attachment:
# We could not access the attachment
self.logger.error(
'Could not access Office 365 attachment {}.'.format(
attachment.url(privacy=True)))
return False
# Our Session URL
url = \
'{graph_url}/v1.0/users/{userid}/message/{message_id}' \
.format(
graph_url=self.graph_url,
userid=self.source,
message_id=message_id,
) + '/attachments/createUploadSession'
file_size = len(attachment)
payload = {
"AttachmentItem": {
"attachmentType": "file",
"name": name if name else (
attachment.name
if attachment.name else '{}.dat'.format(str(uuid4()))),
# MIME type of the attachment
"contentType": attachment.mimetype,
"size": file_size,
}
}
if not self.authenticate():
# We could not authenticate ourselves; we're done
return False
# Get our Upload URL
postokay, response = self._fetch(url, payload)
if not postokay:
return False
upload_url = response.get('uploadUrl')
if not upload_url:
return False
start_byte = 0
postokay = False
response = None
for chunk in attachment.chunk():
end_byte = start_byte + len(chunk) - 1
# Define headers for this chunk
headers = {
'User-Agent': self.app_id,
'Content-Length': str(len(chunk)),
'Content-Range':
f'bytes {start_byte}-{end_byte}/{file_size}'
}
# Upload the chunk
postokay, response = self._fetch(
upload_url, chunk, headers=headers, content_type=None,
method='PUT')
if not postokay:
return False
# Return our Upload URL
return postokay
def authenticate(self):
"""
Logs into and acquires us an authentication token to work with
@ -420,12 +674,12 @@ class NotifyOffice365(NotifyBase):
# Prepare our payload
payload = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.secret,
'scope': '{graph_url}/{scope}'.format(
graph_url=self.graph_url,
scope=self.scope),
'grant_type': 'client_credentials',
}
# Prepare our URL
@ -453,7 +707,9 @@ class NotifyOffice365(NotifyBase):
# "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7"
# }
postokay, response = self._fetch(url=url, payload=payload)
postokay, response = self._fetch(
url=url, payload=payload,
content_type='application/x-www-form-urlencoded')
if not postokay:
return False
@ -480,18 +736,19 @@ class NotifyOffice365(NotifyBase):
# We're authenticated
return True if self.token else False
def _fetch(self, url, payload,
content_type='application/x-www-form-urlencoded'):
def _fetch(self, url, payload=None, headers=None,
content_type='application/json', method='POST'):
"""
Wrapper to request object
"""
# Prepare our headers:
headers = {
'User-Agent': self.app_id,
'Content-Type': content_type,
}
if not headers:
headers = {
'User-Agent': self.app_id,
'Content-Type': content_type,
}
if self.token:
# Are we authenticated?
@ -501,36 +758,84 @@ class NotifyOffice365(NotifyBase):
content = {}
# Some Debug Logging
self.logger.debug('Office 365 POST URL: {} (cert_verify={})'.format(
url, self.verify_certificate))
self.logger.debug('Office 365 %s URL: {} (cert_verify={})'.format(
url, self.verify_certificate), method)
self.logger.debug('Office 365 Payload: {}' .format(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
# fetch function
req = requests.post if method == 'POST' else (
requests.put if method == 'PUT' else requests.get)
try:
r = requests.post(
r = req(
url,
data=payload,
data=json.dumps(payload)
if content_type and content_type.endswith('/json')
else 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.ok, requests.codes.created,
requests.codes.accepted):
# We had a problem
status_str = \
NotifyOffice365.http_response_code_lookup(r.status_code)
self.logger.warning(
'Failed to send Office 365 POST to {}: '
'Failed to send Office 365 %s to {}: '
'{}error={}.'.format(
url,
', ' if status_str else '',
r.status_code))
r.status_code), method)
# A Response could look like this if a Scope element was not
# found:
# {
# "error": {
# "code": "MissingClaimType",
# "message":"The token is missing the claim type \'oid\'.",
# "innerError": {
# "oAuthEventOperationId":" 7abe20-339f-4659-9381-38f52",
# "oAuthEventcV": "xsOSpAHSHVm3Tp4SNH5oIA.1.1",
# "errorUrl": "https://url",
# "requestId": "2328ea-ec9e-43a8-80f4-164c",
# "date":"2024-12-01T02:03:13"
# }}
# }
# Error 403; the below is returned if he User.Read.All
# Application scope is not set and a lookup is
# attempted.
# {
# "error": {
# "code": "Authorization_RequestDenied",
# "message":
# "Insufficient privileges to complete the operation.",
# "innerError": {
# "date": "2024-12-06T00:15:57",
# "request-id":
# "48fdb3e7-2f1a-4f45-a5a0-99b8b851278b",
# "client-request-id": "48f-2f1a-4f45-a5a0-99b8"
# }
# }
# }
# Another response type (error 415):
# {
# "error": {
# "code": "RequestBodyRead",
# "message": "A missing or empty content type header was \
# found when trying to read a message. The content \
# type header is required.",
# }
# }
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
@ -539,7 +844,7 @@ class NotifyOffice365(NotifyBase):
return (False, content)
try:
content = loads(r.content)
content = json.loads(r.content)
except (AttributeError, TypeError, ValueError):
# ValueError = r.content is Unparsable
@ -549,8 +854,8 @@ class NotifyOffice365(NotifyBase):
except requests.RequestException as e:
self.logger.warning(
'Exception received when sending Office 365 POST to {}: '.
format(url))
'Exception received when sending Office 365 %s to {}: '.
format(url), method)
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
@ -558,12 +863,24 @@ class NotifyOffice365(NotifyBase):
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.source, self.tenant, self.client_id,
self.secret,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Our URL parameters
# Extend our parameters
params = self.url_parameters(privacy=privacy, *args, **kwargs)
if self.cc:
@ -580,13 +897,13 @@ class NotifyOffice365(NotifyBase):
'' if not self.names.get(e)
else '{}:'.format(self.names[e]), e) for e in self.bcc])
return '{schema}://{tenant}:{email}/{client_id}/{secret}' \
return '{schema}://{source}/{tenant}/{client_id}/{secret}' \
'/{targets}/?{params}'.format(
schema=self.secure_protocol,
schema=self.secure_protocol[0],
tenant=self.pprint(self.tenant, privacy, safe=''),
# email does not need to be escaped because it should
# already be a valid host and username at this point
email=self.email,
source=self.source,
client_id=self.pprint(self.client_id, privacy, safe=''),
secret=self.pprint(
self.secret, privacy, mode=PrivacyMode.Secret,
@ -594,7 +911,7 @@ class NotifyOffice365(NotifyBase):
targets='/'.join(
[NotifyOffice365.quote('{}{}'.format(
'' if not e[0] else '{}:'.format(e[0]), e[1]),
safe='') for e in self.targets]),
safe='@') for e in self.targets]),
params=NotifyOffice365.urlencode(params))
def __len__(self):
@ -623,16 +940,52 @@ class NotifyOffice365(NotifyBase):
# of the secret key (since it can contain slashes in it)
entries = NotifyOffice365.split_path(results['fullpath'])
try:
# Initialize our tenant
results['tenant'] = None
# Initialize our email
results['email'] = None
# From Email
if 'from' in results['qsd'] and \
len(results['qsd']['from']):
# Extract the sending account's information
results['source'] = \
NotifyOffice365.unquote(results['qsd']['from'])
# If tenant is occupied, then the user defined makes up our source
elif results['user']:
results['source'] = '{}@{}'.format(
NotifyOffice365.unquote(results['user']),
NotifyOffice365.unquote(results['host']),
)
else:
# Object ID instead of email
results['source'] = NotifyOffice365.unquote(results['host'])
# Tenant
if 'tenant' in results['qsd'] and len(results['qsd']['tenant']):
# Extract the Tenant from the argument
results['tenant'] = \
NotifyOffice365.unquote(results['qsd']['tenant'])
elif entries:
results['tenant'] = NotifyOffice365.unquote(entries.pop(0))
# OAuth2 ID
if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']):
# Extract the API Key from an argument
results['client_id'] = \
NotifyOffice365.unquote(results['qsd']['oauth_id'])
elif entries:
# Get our client_id is the first entry on the path
results['client_id'] = NotifyOffice365.unquote(entries.pop(0))
except IndexError:
# no problem, we may get the client_id another way through
# arguments...
pass
#
# Prepare our target listing
#
results['targets'] = list()
while entries:
# Pop the last entry
@ -650,36 +1003,6 @@ class NotifyOffice365(NotifyBase):
# We're done
break
# Initialize our tenant
results['tenant'] = None
# Assemble our secret key which is a combination of the host followed
# by all entries in the full path that follow up until the first email
results['secret'] = '/'.join(
[NotifyOffice365.unquote(x) for x in entries])
# Assemble our client id from the user@hostname
if results['password']:
results['email'] = '{}@{}'.format(
NotifyOffice365.unquote(results['password']),
NotifyOffice365.unquote(results['host']),
)
# Update our tenant
results['tenant'] = NotifyOffice365.unquote(results['user'])
else:
# No tenant specified..
results['email'] = '{}@{}'.format(
NotifyOffice365.unquote(results['user']),
NotifyOffice365.unquote(results['host']),
)
# OAuth2 ID
if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']):
# Extract the API Key from an argument
results['client_id'] = \
NotifyOffice365.unquote(results['qsd']['oauth_id'])
# OAuth2 Secret
if 'oauth_secret' in results['qsd'] and \
len(results['qsd']['oauth_secret']):
@ -687,19 +1010,12 @@ class NotifyOffice365(NotifyBase):
results['secret'] = \
NotifyOffice365.unquote(results['qsd']['oauth_secret'])
# Tenant
if 'from' in results['qsd'] and \
len(results['qsd']['from']):
# Extract the sending account's information
results['email'] = \
NotifyOffice365.unquote(results['qsd']['from'])
# Tenant
if 'tenant' in results['qsd'] and \
len(results['qsd']['tenant']):
# Extract the Tenant from the argument
results['tenant'] = \
NotifyOffice365.unquote(results['qsd']['tenant'])
else:
# Assemble our secret key which is a combination of the host
# followed by all entries in the full path that follow up until
# the first email
results['secret'] = '/'.join(
[NotifyOffice365.unquote(x) for x in entries])
# Support the 'to' variable so that we can support targets this way too
# The 'to' makes it easier to use yaml configuration

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -40,10 +40,8 @@ from itertools import chain
from .base import NotifyBase
from ..common import NotifyType
from ..common import NotifyImageSize
from ..utils import validate_regex
from ..utils import parse_list
from ..utils import parse_bool
from ..utils import is_email
from ..utils.base64 import decode_b64_dict, encode_b64_dict
from ..utils.parse import validate_regex, parse_list, parse_bool, is_email
from ..locale import gettext_lazy as _
@ -82,7 +80,7 @@ class NotifyOneSignal(NotifyBase):
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_onesignal'
# Notification
notify_url = "https://onesignal.com/api/v1/notifications"
notify_url = "https://api.onesignal.com/notifications"
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_72
@ -161,6 +159,18 @@ class NotifyOneSignal(NotifyBase):
'type': 'bool',
'default': False,
},
'contents': {
'name': _('Enable Contents'),
'type': 'bool',
'default': True,
'map_to': 'use_contents',
},
'decode': {
'name': _('Decode Template Args'),
'type': 'bool',
'default': False,
'map_to': 'decode_tpl_args',
},
'template': {
'alias_of': 'template',
},
@ -175,9 +185,22 @@ class NotifyOneSignal(NotifyBase):
},
})
# Define our token control
template_kwargs = {
'custom': {
'name': _('Custom Data'),
'prefix': ':',
},
'postback': {
'name': _('Postback Data'),
'prefix': '+',
},
}
def __init__(self, app, apikey, targets=None, include_image=True,
template=None, subtitle=None, language=None, batch=False,
**kwargs):
template=None, subtitle=None, language=None, batch=None,
use_contents=None, decode_tpl_args=None,
custom=None, postback=None, **kwargs):
"""
Initialize OneSignal
@ -201,7 +224,19 @@ class NotifyOneSignal(NotifyBase):
raise TypeError(msg)
# Prepare Batch Mode Flag
self.batch_size = self.default_batch_size if batch else 1
self.batch_size = self.default_batch_size if (
batch if batch is not None else
self.template_args['batch']['default']) else 1
# Prepare Use Contents Flag
self.use_contents = True if (
use_contents if use_contents is not None else
self.template_args['contents']['default']) else False
# Prepare Decode Template Arguments Flag
self.decode_tpl_args = True if (
decode_tpl_args if decode_tpl_args is not None else
self.template_args['decode']['default']) else False
# Place a thumbnail image inline with the message body
self.include_image = include_image
@ -273,6 +308,30 @@ class NotifyOneSignal(NotifyBase):
'Detected OneSignal Player ID: %s' %
self.targets[OneSignalCategory.PLAYER][-1])
# Custom Data
self.custom_data = {}
if custom and isinstance(custom, dict):
if self.decode_tpl_args:
custom = decode_b64_dict(custom)
self.custom_data.update(custom)
elif custom:
msg = 'The specified OneSignal Custom Data ' \
'({}) are not identified as a dictionary.'.format(custom)
self.logger.warning(msg)
raise TypeError(msg)
# Postback Data
self.postback_data = {}
if postback and isinstance(postback, dict):
self.postback_data.update(postback)
elif postback:
msg = 'The specified OneSignal Postback Data ' \
'({}) are not identified as a dictionary.'.format(postback)
self.logger.warning(msg)
raise TypeError(msg)
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
@ -291,14 +350,9 @@ class NotifyOneSignal(NotifyBase):
payload = {
'app_id': self.app,
'headings': {
self.language: title if title else self.app_desc,
},
'contents': {
self.language: body,
},
# Sending true wakes your app from background to run custom native
# code (Apple interprets this as content-available=1).
# Note: Not applicable if the app is in the "force-quit" state
@ -307,6 +361,33 @@ class NotifyOneSignal(NotifyBase):
'content_available': True,
}
if self.template_id:
# Store template information
payload['template_id'] = self.template_id
if not self.use_contents:
# Only if a template is defined can contents be removed
del payload['contents']
# Set our data if defined
if self.custom_data:
payload.update({
'custom_data': self.custom_data,
})
# Set our postback data if defined
if self.postback_data:
payload.update({
'data': self.postback_data,
})
if title:
# Display our title if defined
payload.update({
'headings': {
self.language: title,
}})
if self.subtitle:
payload.update({
'subtitle': {
@ -314,9 +395,6 @@ class NotifyOneSignal(NotifyBase):
},
})
if self.template_id:
payload['template_id'] = self.template_id
# Acquire our large_icon image URL (if set)
image_url = None if not self.include_image \
else self.image_url(notify_type)
@ -392,6 +470,17 @@ class NotifyOneSignal(NotifyBase):
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.template_id, self.app, self.apikey,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -406,6 +495,25 @@ class NotifyOneSignal(NotifyBase):
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
custom_data, needs_decoding = encode_b64_dict(self.custom_data)
# custom_data, needs_decoding = self.custom_data, False
# Save our template data
params.update(
{':{}'.format(k): v for k, v in custom_data.items()}
)
# Save our postback data
params.update(
{'+{}'.format(k): v for k, v in self.postback_data.items()})
if self.use_contents != self.template_args['contents']['default']:
params['contents'] = 'yes' if self.use_contents else 'no'
if (self.decode_tpl_args != self.template_args['decode']['default']
or needs_decoding):
params['decode'] = 'yes' if (self.decode_tpl_args or
needs_decoding) else 'no'
return '{schema}://{tp_id}{app}@{apikey}/{targets}?{params}'.format(
schema=self.secure_protocol,
tp_id='{}:'.format(
@ -485,6 +593,20 @@ class NotifyOneSignal(NotifyBase):
'batch',
NotifyOneSignal.template_args['batch']['default']))
# Get Use Contents Boolean (if set)
results['use_contents'] = \
parse_bool(
results['qsd'].get(
'contents',
NotifyOneSignal.template_args['contents']['default']))
# Get Use Contents Boolean (if set)
results['decode_tpl_args'] = \
parse_bool(
results['qsd'].get(
'decode',
NotifyOneSignal.template_args['decode']['default']))
# The API Key is stored in the hostname
results['apikey'] = NotifyOneSignal.unquote(results['host'])
@ -516,4 +638,10 @@ class NotifyOneSignal(NotifyBase):
results['language'] = \
NotifyOneSignal.unquote(results['qsd']['lang'])
# Store our custom data
results['custom'] = results['qsd:']
# Store our postback data
results['postback'] = results['qsd+']
return results

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -47,14 +47,13 @@
# API Integration Docs: https://docs.opsgenie.com/docs/api-integration
import requests
from json import dumps
from json import dumps, loads
import hashlib
from .base import NotifyBase
from ..common import NotifyType
from ..utils import validate_regex
from ..utils import is_uuid
from ..utils import parse_list
from ..utils import parse_bool
from ..common import NotifyType, NOTIFY_TYPES
from ..common import PersistentStoreMode
from ..utils.parse import validate_regex, is_uuid, parse_list, parse_bool
from ..locale import gettext_lazy as _
@ -76,6 +75,47 @@ OPSGENIE_CATEGORIES = (
)
class OpsgenieAlertAction:
"""
Defines the supported actions
"""
# Use mapping (specify :key=arg to over-ride)
MAP = 'map'
# Create new alert (default)
NEW = 'new'
# Close Alert
CLOSE = 'close'
# Delete Alert
DELETE = 'delete'
# Acknowledge Alert
ACKNOWLEDGE = 'acknowledge'
# Add note to alert
NOTE = 'note'
OPSGENIE_ACTIONS = (
OpsgenieAlertAction.MAP,
OpsgenieAlertAction.NEW,
OpsgenieAlertAction.CLOSE,
OpsgenieAlertAction.DELETE,
OpsgenieAlertAction.ACKNOWLEDGE,
OpsgenieAlertAction.NOTE,
)
# Map all support Apprise Categories to Opsgenie Categories
OPSGENIE_ALERT_MAP = {
NotifyType.INFO: OpsgenieAlertAction.CLOSE,
NotifyType.SUCCESS: OpsgenieAlertAction.CLOSE,
NotifyType.WARNING: OpsgenieAlertAction.NEW,
NotifyType.FAILURE: OpsgenieAlertAction.NEW,
}
# Regions
class OpsgenieRegion:
US = 'us'
@ -160,6 +200,10 @@ class NotifyOpsgenie(NotifyBase):
# The maximum length of the body
body_maxlen = 15000
# Our default is to no not use persistent storage beyond in-memory
# reference
storage_mode = PersistentStoreMode.AUTO
# If we don't have the specified min length, then we don't bother using
# the body directive
opsgenie_body_minlen = 130
@ -170,10 +214,24 @@ class NotifyOpsgenie(NotifyBase):
# The maximum allowable targets within a notification
default_batch_size = 50
# Defines our default message mapping
opsgenie_message_map = {
# Add a note to existing alert
NotifyType.INFO: OpsgenieAlertAction.NOTE,
# Close existing alert
NotifyType.SUCCESS: OpsgenieAlertAction.CLOSE,
# Create notice
NotifyType.WARNING: OpsgenieAlertAction.NEW,
# Create notice
NotifyType.FAILURE: OpsgenieAlertAction.NEW,
}
# Define object templates
templates = (
'{schema}://{apikey}',
'{schema}://{user}@{apikey}',
'{schema}://{apikey}/{targets}',
'{schema}://{user}@{apikey}/{targets}',
)
# Define our template tokens
@ -184,6 +242,10 @@ class NotifyOpsgenie(NotifyBase):
'private': True,
'required': True,
},
'user': {
'name': _('Username'),
'type': 'string',
},
'target_escalation': {
'name': _('Target Escalation'),
'prefix': '^',
@ -249,6 +311,12 @@ class NotifyOpsgenie(NotifyBase):
'to': {
'alias_of': 'targets',
},
'action': {
'name': _('Action'),
'type': 'choice:string',
'values': OPSGENIE_ACTIONS,
'default': OPSGENIE_ACTIONS[0],
}
})
# Map of key-value pairs to use as custom properties of the alert.
@ -257,11 +325,15 @@ class NotifyOpsgenie(NotifyBase):
'name': _('Details'),
'prefix': '+',
},
'mapping': {
'name': _('Action Mapping'),
'prefix': ':',
},
}
def __init__(self, apikey, targets, region_name=None, details=None,
priority=None, alias=None, entity=None, batch=False,
tags=None, **kwargs):
tags=None, action=None, mapping=None, **kwargs):
"""
Initialize Opsgenie Object
"""
@ -298,6 +370,41 @@ class NotifyOpsgenie(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
if action and isinstance(action, str):
self.action = next(
(a for a in OPSGENIE_ACTIONS if a.startswith(action)), None)
if self.action not in OPSGENIE_ACTIONS:
msg = 'The Opsgenie action specified ({}) is invalid.'\
.format(action)
self.logger.warning(msg)
raise TypeError(msg)
else:
self.action = self.template_args['action']['default']
# Store our mappings
self.mapping = self.opsgenie_message_map.copy()
if mapping and isinstance(mapping, dict):
for _k, _v in mapping.items():
# Get our mapping
k = next((t for t in NOTIFY_TYPES if t.startswith(_k)), None)
if not k:
msg = 'The Opsgenie mapping key specified ({}) ' \
'is invalid.'.format(_k)
self.logger.warning(msg)
raise TypeError(msg)
_v_lower = _v.lower()
v = next((v for v in OPSGENIE_ACTIONS[1:]
if v.startswith(_v_lower)), None)
if not v:
msg = 'The Opsgenie mapping value (assigned to {}) ' \
'specified ({}) is invalid.'.format(k, _v)
self.logger.warning(msg)
raise TypeError(msg)
# Update our mapping
self.mapping[k] = v
self.details = {}
if details:
# Store our extra details
@ -367,115 +474,234 @@ class NotifyOpsgenie(NotifyBase):
if is_uuid(target) else
{'type': OpsgenieCategory.USER, 'username': target})
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
def _fetch(self, method, url, payload, params=None):
"""
Perform Opsgenie Notification
Performs server retrieval/update and returns JSON Response
"""
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'Authorization': 'GenieKey {}'.format(self.apikey),
}
# Some Debug Logging
self.logger.debug(
'Opsgenie POST URL: {} (cert_verify={})'.format(
url, self.verify_certificate))
self.logger.debug('Opsgenie Payload: {}' .format(payload))
# Initialize our response object
content = {}
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = method(
url,
data=dumps(payload),
params=params,
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
# A Response might look like:
# {
# "result": "Request will be processed",
# "took": 0.302,
# "requestId": "43a29c5c-3dbf-4fa4-9c26-f4f71023e120"
# }
try:
# Update our response object
content = loads(r.content)
except (AttributeError, TypeError, ValueError):
# ValueError = r.content is Unparsable
# TypeError = r.content is None
# AttributeError = r is None
content = {}
if r.status_code not in (
requests.codes.accepted, requests.codes.ok):
status_str = \
NotifyBase.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Opsgenie notification:'
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
return (False, content.get('requestId'))
# If we reach here; the message was sent
self.logger.info('Sent Opsgenie notification')
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
return (True, content.get('requestId'))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Opsgenie '
'notification.')
self.logger.debug('Socket Exception: %s' % str(e))
return (False, content.get('requestId'))
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Opsgenie Notification
"""
# Get our Opsgenie Action
action = OPSGENIE_ALERT_MAP[notify_type] \
if self.action == OpsgenieAlertAction.MAP else self.action
# Prepare our URL as it's based on our hostname
notify_url = OPSGENIE_API_LOOKUP[self.region_name]
# Initialize our has_error flag
has_error = False
# Use body if title not set
title_body = body if not title else title
# Default method is to post
method = requests.post
# Create a copy ouf our details object
details = self.details.copy()
if 'type' not in details:
details['type'] = notify_type
# For indexing in persistent store
key = hashlib.sha1(
(self.entity if self.entity else (
self.alias if self.alias else (
title if title else self.app_id)))
.encode('utf-8')).hexdigest()[0:10]
# Prepare our payload
payload = {
'source': self.app_desc,
'message': title_body,
'description': body,
'details': details,
'priority': 'P{}'.format(self.priority),
}
# Get our Opsgenie Request IDs
request_ids = self.store.get(key, [])
if not isinstance(request_ids, list):
request_ids = []
# Use our body directive if we exceed the minimum message
# limitation
if len(payload['message']) > self.opsgenie_body_minlen:
payload['message'] = '{}...'.format(
title_body[:self.opsgenie_body_minlen - 3])
if action == OpsgenieAlertAction.NEW:
# Create a copy ouf our details object
details = self.details.copy()
if 'type' not in details:
details['type'] = notify_type
if self.__tags:
payload['tags'] = self.__tags
# Use body if title not set
title_body = body if not title else title
if self.entity:
payload['entity'] = self.entity
# Prepare our payload
payload = {
'source': self.app_desc,
'message': title_body,
'description': body,
'details': details,
'priority': 'P{}'.format(self.priority),
}
if self.alias:
payload['alias'] = self.alias
# Use our body directive if we exceed the minimum message
# limitation
if len(payload['message']) > self.opsgenie_body_minlen:
payload['message'] = '{}...'.format(
title_body[:self.opsgenie_body_minlen - 3])
length = len(self.targets) if self.targets else 1
for index in range(0, length, self.batch_size):
if self.targets:
# If there were no targets identified, then we simply
# just iterate once without the responders set
payload['responders'] = \
self.targets[index:index + self.batch_size]
if self.__tags:
payload['tags'] = self.__tags
# Some Debug Logging
self.logger.debug(
'Opsgenie POST URL: {} (cert_verify={})'.format(
notify_url, self.verify_certificate))
self.logger.debug('Opsgenie Payload: {}' .format(payload))
if self.entity:
payload['entity'] = self.entity
# 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,
)
if self.alias:
payload['alias'] = self.alias
if r.status_code not in (
requests.codes.accepted, requests.codes.ok):
status_str = \
NotifyBase.http_response_code_lookup(
r.status_code)
if self.user:
payload['user'] = self.user
self.logger.warning(
'Failed to send Opsgenie notification:'
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
# reset our request IDs - we will re-populate them
request_ids = []
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
length = len(self.targets) if self.targets else 1
for index in range(0, length, self.batch_size):
if self.targets:
# If there were no targets identified, then we simply
# just iterate once without the responders set
payload['responders'] = \
self.targets[index:index + self.batch_size]
# Mark our failure
# Perform our post
success, request_id = self._fetch(
method, notify_url, payload)
if success and request_id:
# Save our response
request_ids.append(request_id)
else:
has_error = True
continue
# If we reach here; the message was sent
self.logger.info('Sent Opsgenie notification')
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
# Store our entries for a maximum of 60 days
self.store.set(key, request_ids, expires=60 * 60 * 24 * 60)
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Opsgenie '
'notification.')
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
elif request_ids:
# Prepare our payload
payload = {
'source': self.app_desc,
'note': body,
}
if self.user:
payload['user'] = self.user
# Prepare our Identifier type
params = {
'identifierType': 'id',
}
for request_id in request_ids:
if action == OpsgenieAlertAction.DELETE:
# Update our URL
url = f'{notify_url}/{request_id}'
method = requests.delete
elif action == OpsgenieAlertAction.ACKNOWLEDGE:
url = f'{notify_url}/{request_id}/acknowledge'
elif action == OpsgenieAlertAction.CLOSE:
url = f'{notify_url}/{request_id}/close'
else: # action == OpsgenieAlertAction.CLOSE:
url = f'{notify_url}/{request_id}/notes'
# Perform our post
success, _ = self._fetch(method, url, payload, params)
if not success:
has_error = True
if not has_error and action == OpsgenieAlertAction.DELETE:
# Remove cached entry
self.store.clear(key)
else:
self.logger.info(
'No Opsgenie notification sent due to (nothing to %s) '
'condition', self.action)
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.region_name, self.apikey)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
@ -483,6 +709,7 @@ class NotifyOpsgenie(NotifyBase):
# Define any URL parameters
params = {
'action': self.action,
'region': self.region_name,
'priority':
OPSGENIE_PRIORITIES[self.template_args['priority']['default']]
@ -506,6 +733,10 @@ class NotifyOpsgenie(NotifyBase):
# Append our details into our parameters
params.update({'+{}'.format(k): v for k, v in self.details.items()})
# Append our assignment extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.mapping.items()})
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
@ -522,8 +753,9 @@ class NotifyOpsgenie(NotifyBase):
NotifyOpsgenie.template_tokens['target_team']['prefix'],
}
return '{schema}://{apikey}/{targets}/?{params}'.format(
return '{schema}://{user}{apikey}/{targets}/?{params}'.format(
schema=self.secure_protocol,
user='{}@'.format(self.user) if self.user else '',
apikey=self.pprint(self.apikey, privacy, safe=''),
targets='/'.join(
[NotifyOpsgenie.quote('{}{}'.format(
@ -608,4 +840,14 @@ class NotifyOpsgenie(NotifyBase):
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'].append(results['qsd']['to'])
# Store our action (if defined)
if 'action' in results['qsd'] and len(results['qsd']['action']):
results['action'] = \
NotifyOpsgenie.unquote(results['qsd']['action'])
# store any custom mapping defined
results['mapping'] = \
{NotifyOpsgenie.unquote(x): NotifyOpsgenie.unquote(y)
for x, y in results['qsd:'].items()}
return results

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -38,8 +38,7 @@ from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyType
from ..common import NotifyImageSize
from ..utils import validate_regex
from ..utils import parse_bool
from ..utils.parse import validate_regex, parse_bool
from ..locale import gettext_lazy as _
@ -412,6 +411,18 @@ class NotifyPagerDuty(NotifyBase):
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, self.integration_key, self.apikey,
self.source,
)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

View file

@ -2,7 +2,7 @@
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
@ -33,8 +33,7 @@ from uuid import uuid4
from .base import NotifyBase
from ..common import NotifyType
from ..utils import parse_list
from ..utils import validate_regex
from ..utils.parse import parse_list, validate_regex
from ..locale import gettext_lazy as _
@ -299,6 +298,15 @@ class NotifyPagerTree(NotifyBase):
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, self.integration)
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.

Some files were not shown because too many files have changed in this diff Show more