Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/github-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ jobs:
name: Build and Push
uses: ./.github/actions/docker-build
with:
images: ghcr.io/YoRyan/mailrise
images: ghcr.io/${{ github.repository }}
19 changes: 17 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ sub-dictionaries):
====================================== ========== ==========================================================================
Key Type Value
====================================== ========== ==========================================================================
configs.<name> dictionary ``<name>`` denotes the email address associated with the configuration.
configs.<name> dictionary ``<name>`` denotes the recipient email address associated with the configuration.
Senders should address their emails to this address. ``<name>`` can be a
full email address, such as ``notify@mydomain.com``, or it can be a
username only, such as ``notify``, in which case the default
Expand All @@ -182,6 +182,10 @@ configs.<name> dictionary ``<name>`` denotes the email a

In addition to the Apprise configuration, some Mailrise-exclusive options
can be specified under this key. See the ``mailrise`` options below.
configs.<sender>.<recipient> dictionary For sender-scoped routing, nest recipient configurations under a sender
address. The ``<sender>`` and ``<recipient>`` keys use the same address
and pattern rules as ``configs.<name>``. To match any recipient from a
sender, use ``*@*`` as the recipient key.
configs.<name>.mailrise.title_template string The template string used to create notification titles. See "Template
strings" below.

Expand Down Expand Up @@ -319,6 +323,17 @@ underlying JSON structure, a useful aid.
urls:
- pover://USER_KEY@TOKEN

# You can route by sender and recipient by nesting recipient targets
# under sender addresses.
#
"monitoring@example.com":
"alerts@mycooldomain.com":
urls:
- pover://USER_KEY@TOKEN
"*@*":
urls:
- discord://WEBHOOK_ID/WEBHOOK_TOKEN

# Wildcard targets are evaluated in the order they appear in the
# configuration file, and Mailrise uses the first match. So, this config
# will catch any addresses not matched by the previous targets.
Expand Down Expand Up @@ -407,4 +422,4 @@ For further details, refer to the
`Mailrise router API
<https://github.com/YoRyan/mailrise/blob/main/src/mailrise/router.py>`_, and
the `aiosmtpd authenticator callback
<https://aiosmtpd.readthedocs.io/en/latest/auth.html#authenticator-callback>`_.
<https://aiosmtpd.readthedocs.io/en/latest/auth.html#authenticator-callback>`_.
54 changes: 43 additions & 11 deletions src/mailrise/simple_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,24 @@ class SimpleRouter(Router): # pylint: disable=too-few-public-methods
tuple, where key contains username and domain patterns that can be
matched by fnmatch and sender is the Sender instance itself.
"""
senders: typ.List[typ.Tuple[_Key, _SimpleSender]]
senders: typ.List[typ.Tuple[_Key, _Key, _SimpleSender]]

def __init__(self, senders: typ.List[typ.Tuple[_Key, _SimpleSender]]):
def __init__(self, senders: typ.List[typ.Tuple[_Key, _Key, _SimpleSender]]):
super().__init__()
self.senders = senders

async def email_to_apprise(
self, logger: Logger, email: EmailMessage, auth_data: typ.Any, **kwargs) \
-> typ.AsyncGenerator[AppriseNotification, None]:
sender_addr = email.from_
for addr in email.to:
try:
sender_mail = _parsercpt(sender_addr)
rcpt = _parsercpt(addr)
except ValueError:
logger.error('Not a valid Mailrise address: %s', addr)
continue
sender = self.get_sender(rcpt.key)
sender = self.get_sender(sender_mail.key, rcpt.key)
if sender is None:
logger.error('Recipient is not configured: %s', addr)
continue
Expand All @@ -141,23 +143,53 @@ async def email_to_apprise(
attachments=email.attachments
)

def get_sender(self, key: _Key) -> _SimpleSender | None:
def get_sender(self, sender_key: _Key, rect_key: _Key) -> _SimpleSender | None:
"""Find a sender by recipient key."""
return next(
(sender for (pattern_key, sender) in self.senders
if fnmatchcase(key.user, pattern_key.user)
and fnmatchcase(key.domain, pattern_key.domain)), None)
(sender for (sender_pattern_key, rect_pattern_key, sender) in self.senders
if fnmatchcase(sender_key.user, sender_pattern_key.user)
and fnmatchcase(sender_key.domain, sender_pattern_key.domain)
and fnmatchcase(rect_key.user, rect_pattern_key.user)
and fnmatchcase(rect_key.domain, rect_pattern_key.domain)), None)


def load_from_yaml(logger: Logger, configs_node: dict[str, typ.Any]) -> SimpleRouter:
"""Load a simple router from the YAML configs node."""
if not isinstance(configs_node, dict):
logger.critical('The configs node is not a YAML mapping')
raise SystemExit(1)
router = SimpleRouter(
senders=[(_parse_simple_key(logger, key), _load_simple_sender(logger, key, config))
for key, config in configs_node.items()]
)

senders = []
for sender_key, rev_config in configs_node.items():
if not isinstance(rev_config, dict):
logger.critical("YAML config node '%s' is not a mapping", sender_key)
raise SystemExit(1)

# Check if rev_config contains receiver-level nesting or direct config
# A direct config will have 'urls' or 'mailrise' keys at the top level
is_direct_config = 'urls' in rev_config or any(
k for k in rev_config.keys()
if not isinstance(rev_config[k], dict) or k == 'mailrise'
)

if is_direct_config:
# Legacy direct config: receiver -> config.
default_sender = "*@*"
senders.append((
_parse_simple_key(logger, default_sender),
_parse_simple_key(logger, sender_key),
_load_simple_sender(logger, sender_key, rev_config)
))
else:
# Nested config: sender -> receiver -> config
for recv_key, config in rev_config.items():
senders.append((
_parse_simple_key(logger, sender_key),
_parse_simple_key(logger, recv_key),
_load_simple_sender(logger, sender_key, config)
))

router = SimpleRouter(senders=senders)
if len(router.senders) < 1:
logger.critical('No Apprise targets are configured')
raise SystemExit(1)
Expand Down
70 changes: 50 additions & 20 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,38 @@ def test_load() -> None:
router = mrise.router
assert isinstance(router, SimpleRouter)
assert len(router.senders) == 1
key = _Key(user='test')
sender_key = _Key(user='*@*')
rect_key = _Key(user='test')
assert mrise.authenticator is None

sender = router.get_sender(key)
sender = router.get_sender(sender_key, rect_key)
assert sender is not None
notifier = _make_notifier(sender.config_yaml)
assert len(notifier) == 1
assert notifier[0].url().startswith('json://localhost/')

def test_sender_receiver_load() -> None:
"""Tests a successful load with :fun:`load_config`."""
file = StringIO("""
configs:
test:
receiver:
urls:
- json://localhost
""")
mrise = load_config(_logger, file)
router = mrise.router
assert isinstance(router, SimpleRouter)
assert len(router.senders) == 1
sender_key = _Key(user='test')
rect_key = _Key(user='receiver')
assert mrise.authenticator is None

sender = router.get_sender(sender_key, rect_key)
assert sender is not None
notifier = _make_notifier(sender.config_yaml)
assert len(notifier) == 1
assert notifier[0].url().startswith('json://localhost/')

def test_multi_load() -> None:
"""Tests a sucessful load with :fun:`load_config` with multiple configs."""
Expand All @@ -76,9 +99,10 @@ def test_multi_load() -> None:
assert len(router.senders) == 2

for user in ('test1', 'test2'):
key = _Key(user=user)
sender_key = _Key(user='*@*')
rect_key = _Key(user=user)

sender = router.get_sender(key)
sender = router.get_sender(sender_key, rect_key)
assert sender is not None
notifier = _make_notifier(sender.config_yaml)
assert len(notifier) == 1
Expand All @@ -101,9 +125,10 @@ def test_mailrise_options() -> None:
router = mrise.router
assert isinstance(router, SimpleRouter)
assert len(router.senders) == 1
key = _Key(user='test')
sender_key = _Key(user='*@*')
rect_key = _Key(user='test')

sender = router.get_sender(key)
sender = router.get_sender(sender_key, rect_key)
assert sender is not None
assert sender.title_template.template == ''
assert sender.body_format == NotifyFormat.TEXT
Expand Down Expand Up @@ -148,8 +173,9 @@ def test_config_keys() -> None:
router = mrise.router
assert isinstance(router, SimpleRouter)
assert len(router.senders) == 1
key = _Key(user='user', domain='example.com')
assert router.get_sender(key) is not None
sender_key = _Key(user='*@*')
rect_key = _Key(user='user', domain='example.com')
assert router.get_sender(sender_key, rect_key) is not None


def test_fnmatch_config_keys() -> None:
Expand All @@ -165,10 +191,11 @@ def test_fnmatch_config_keys() -> None:
mrise = load_config(_logger, file)
router = mrise.router
assert isinstance(router, SimpleRouter)
key = _Key(user='user', domain='example.com')
assert router.get_sender(key) is None
key = _Key(user='user', domain='mailrise.xyz')
assert router.get_sender(key) is not None
sender_key = _Key(user='sender', domain='example.com')
rect_key = _Key(user='user', domain='example.com')
assert router.get_sender(sender_key, rect_key) is None
rect_key = _Key(user='user', domain='mailrise.xyz')
assert router.get_sender(sender_key, rect_key) is not None

file = StringIO("""
configs:
Expand All @@ -179,8 +206,9 @@ def test_fnmatch_config_keys() -> None:
mrise = load_config(_logger, file)
router = mrise.router
assert isinstance(router, SimpleRouter)
key = _Key(user='user', domain='example.com')
assert router.get_sender(key) is not None
sender_key = _Key(user='sender', domain='example.com')
rect_key = _Key(user='user', domain='example.com')
assert router.get_sender(sender_key, rect_key) is not None

file = StringIO("""
configs:
Expand All @@ -191,10 +219,11 @@ def test_fnmatch_config_keys() -> None:
mrise = load_config(_logger, file)
router = mrise.router
assert isinstance(router, SimpleRouter)
key = _Key(user='user', domain='example.com')
assert router.get_sender(key) is None
key = _Key(user='thequickbrownfox', domain='example.com')
assert router.get_sender(key) is not None
sender_key = _Key(user='sender', domain='example.com')
rect_key = _Key(user='user', domain='example.com')
assert router.get_sender(sender_key, rect_key) is None
rect_key = _Key(user='thequickbrownfox', domain='example.com')
assert router.get_sender(sender_key, rect_key) is not None


def test_authenticator() -> None:
Expand Down Expand Up @@ -242,8 +271,9 @@ def test_env_var() -> None:
router = mrise.router
assert isinstance(router, SimpleRouter)
assert len(router.senders) == 1
key = _Key(user='test')
sender = router.get_sender(key)
sender_key = _Key(user='*@*')
rect_key = _Key(user='test')
sender = router.get_sender(sender_key, rect_key)
assert sender is not None
notifier = _make_notifier(sender.config_yaml)
# Missing type annotation for this property as of Dec 2022.
Expand Down
100 changes: 99 additions & 1 deletion tests/test_simple_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
Tests for the YAML-based router.
"""

from email.message import EmailMessage as StdlibEmailMessage
import logging

import apprise
import pytest

from mailrise.simple_router import _Key, _parsercpt
from mailrise.router import EmailMessage
from mailrise.simple_router import _Key, _parsercpt, load_from_yaml


_logger = logging.getLogger(__name__)


def test_parsercpt() -> None:
Expand Down Expand Up @@ -36,3 +43,94 @@ def test_parsercpt() -> None:

with pytest.raises(ValueError):
_parsercpt("Invalid Email <bad@>")


@pytest.mark.asyncio
async def test_direct_config_routes_by_recipient() -> None:
"""Tests that legacy direct configs still match the recipient address."""
router = load_from_yaml(_logger, {
'alerts': {
'urls': ['json://localhost']
}
})
email = _make_email(
from_='sender@example.com',
to=['alerts@mailrise.xyz'],
)

notifications = [
notification async for notification
in router.email_to_apprise(_logger, email, auth_data=None)
]

assert len(notifications) == 1
assert notifications[0].title == 'Subject (sender@example.com)'


@pytest.mark.asyncio
async def test_nested_config_routes_by_sender_and_recipient() -> None:
"""Tests that nested configs match both sender and recipient addresses."""
router = load_from_yaml(_logger, {
'sender@example.com': {
'alerts@example.net': {
'urls': ['json://localhost']
}
}
})
matching_email = _make_email(
from_='sender@example.com',
to=['alerts@example.net'],
)
other_sender_email = _make_email(
from_='other@example.com',
to=['alerts@example.net'],
)

matching = [
notification async for notification
in router.email_to_apprise(_logger, matching_email, auth_data=None)
]
other_sender = [
notification async for notification
in router.email_to_apprise(_logger, other_sender_email, auth_data=None)
]

assert len(matching) == 1
assert len(other_sender) == 0


@pytest.mark.asyncio
async def test_email_to_apprise_handles_multiple_recipients() -> None:
"""Tests that one email can produce notifications for multiple recipients."""
router = load_from_yaml(_logger, {
'alerts': {
'urls': ['json://localhost']
},
'ops': {
'urls': ['json://localhost']
}
})
email = _make_email(
from_='sender@example.com',
to=['alerts@mailrise.xyz', 'ops@mailrise.xyz'],
)

notifications = [
notification async for notification
in router.email_to_apprise(_logger, email, auth_data=None)
]

assert len(notifications) == 2
assert [notification.body for notification in notifications] == ['Body', 'Body']


def _make_email(from_: str, to: list[str]) -> EmailMessage:
return EmailMessage(
email_message=StdlibEmailMessage(),
subject='Subject',
from_=from_,
to=to,
body='Body',
body_format=apprise.NotifyFormat.TEXT,
attachments=[]
)