Add the bones of an Email Notification service
This commit is contained in:
parent
a7fdd6cbd0
commit
24bb9dae30
8 changed files with 138 additions and 8 deletions
|
|
@ -4,4 +4,8 @@
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info"
|
"level": "info"
|
||||||
}
|
}
|
||||||
|
"email": {
|
||||||
|
"api_key": "asdf1234",
|
||||||
|
"sender": "me@example.com"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import json
|
import json
|
||||||
from logging import INFO
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .logging import Logging
|
from .logging import Logging
|
||||||
|
from .email import Email
|
||||||
from .parse import ConfigParseError, assert_key_of_type
|
from .parse import ConfigParseError, assert_key_of_type
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -12,21 +12,25 @@ class Config:
|
||||||
host: str | None
|
host: str | None
|
||||||
port: int | None
|
port: int | None
|
||||||
logging: Logging
|
logging: Logging
|
||||||
|
email: Email
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, config: dict[str, Any]) -> 'Config':
|
def from_dict(cls, config: dict[str, Any]) -> 'Config':
|
||||||
assert_key_of_type(config, 'logging', dict)
|
assert_key_of_type(config, 'logging', dict)
|
||||||
|
assert_key_of_type(config, 'email', dict)
|
||||||
assert_key_of_type(config, 'host', str)
|
assert_key_of_type(config, 'host', str)
|
||||||
assert_key_of_type(config, 'port', int)
|
assert_key_of_type(config, 'port', int)
|
||||||
try:
|
try:
|
||||||
log_config = Logging.from_dict(config['logging'])
|
log_config = Logging.from_dict(config['logging'])
|
||||||
return Config(host=config['host'], port=config['port'], logging=log_config)
|
|
||||||
|
|
||||||
except ConfigParseError as e:
|
except ConfigParseError as e:
|
||||||
raise ConfigParseError(['logging', *e.keypath], e.issue) from e
|
raise ConfigParseError(['logging', *e.keypath], e.issue) from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_config = Email.from_dict(config['email'])
|
||||||
|
except ConfigParseError as e:
|
||||||
|
raise ConfigParseError(['email', *e.keypath], e.issue) from e
|
||||||
|
|
||||||
DEFAULT_CONFIG = Config(logging=Logging(level=INFO), port=None, host=None)
|
return Config(host=config['host'], port=config['port'], email=email_config, logging=log_config)
|
||||||
|
|
||||||
|
|
||||||
def parse_config(filename: str) -> Config:
|
def parse_config(filename: str) -> Config:
|
||||||
|
|
@ -35,4 +39,4 @@ def parse_config(filename: str) -> Config:
|
||||||
return Config.from_dict(config)
|
return Config.from_dict(config)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['Config', 'parse_config', 'DEFAULT_CONFIG']
|
__all__ = ['Config', 'parse_config']
|
||||||
|
|
|
||||||
18
src/config/email.py
Normal file
18
src/config/email.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from src.config.parse import assert_key_of_type
|
||||||
|
from typing import Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
EmailAddress = str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Email:
|
||||||
|
api_key: str
|
||||||
|
sender: EmailAddress
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, config: dict[str, Any]) -> 'Email':
|
||||||
|
assert_key_of_type(config, 'api_key', str)
|
||||||
|
assert_key_of_type(config, 'sender', str)
|
||||||
|
return Email(api_key=config['api_key'], sender=config['sender'])
|
||||||
|
|
@ -5,7 +5,7 @@ from flask import Flask
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from src.config import parse_config, DEFAULT_CONFIG, Config
|
from src.config import parse_config, Config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -23,9 +23,10 @@ def create_app(name: str, config: Config) -> Flask:
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser('Cereal', description='run the Cereal server')
|
parser = argparse.ArgumentParser('Cereal', description='run the Cereal server')
|
||||||
parser.add_argument('--config', '-c', help='The path to the configuration file, expects JSON')
|
parser.add_argument('--config', '-c', help='The path to the configuration file, expects JSON', required=True)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
config = parse_config(args.config) if args.config else DEFAULT_CONFIG
|
|
||||||
|
config = parse_config(args.config)
|
||||||
app = create_app('Cereal', config)
|
app = create_app('Cereal', config)
|
||||||
logger.info(str(config))
|
logger.info(str(config))
|
||||||
serve(app, host='0.0.0.0' if config.host is None else config.host, port=config.port)
|
serve(app, host='0.0.0.0' if config.host is None else config.host, port=config.port)
|
||||||
|
|
|
||||||
0
src/notifications/__init__.py
Normal file
0
src/notifications/__init__.py
Normal file
38
src/notifications/data.py
Normal file
38
src/notifications/data.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
URL = str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationAction:
|
||||||
|
# A unique identifier for the action
|
||||||
|
action: str
|
||||||
|
# The title to show with the action
|
||||||
|
title: str
|
||||||
|
# Where to navigate to when clicked.
|
||||||
|
navigate: URL | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Notification:
|
||||||
|
"""
|
||||||
|
Details to show in a notification
|
||||||
|
|
||||||
|
Much of the data here is intended to match the arguments to the [showNotification](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification)
|
||||||
|
Web API, but this content is ideally displayable in other contexts (for example in Email or RSS)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The main title content to show
|
||||||
|
title: str
|
||||||
|
# The content of the notification
|
||||||
|
content: str
|
||||||
|
|
||||||
|
# The UNIX timestamp in milliseconds since the epoch associated with this notification
|
||||||
|
timestamp: int
|
||||||
|
# A list of actions included with this notification
|
||||||
|
actions: list[NotificationAction] = field(default_factory=list)
|
||||||
|
# The location of an icon to show as the icon for this notification
|
||||||
|
icon: URL | None = None
|
||||||
|
|
||||||
|
# The location of an image associated with this notification
|
||||||
|
image: URL | None = None
|
||||||
53
src/notifications/email.py
Normal file
53
src/notifications/email.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from typing import Any
|
||||||
|
from email.utils import formatdate, make_msgid
|
||||||
|
import abc
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from ..config.email import Email as EmailConfig
|
||||||
|
from .service import NotificationService
|
||||||
|
from .data import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService(NotificationService):
|
||||||
|
def __init__(self, config: EmailConfig):
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
def notify(self, user: Any, notification: Notification):
|
||||||
|
email = self._prepare_email(user.email, notification)
|
||||||
|
self.send_email(email)
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def send_email(self, email: EmailMessage):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _render_template_text(self, notification: Notification) -> str:
|
||||||
|
# TODO
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _render_template_html(self, notification: Notification) -> str:
|
||||||
|
# TODO
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _prepare_email(self, receiver: str, notification: Notification) -> EmailMessage:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg['From'] = self._config.sender
|
||||||
|
msg['To'] = receiver
|
||||||
|
msg['Subject'] = notification.title
|
||||||
|
|
||||||
|
msg['Date'] = formatdate(localtime=True)
|
||||||
|
msg['Message-ID'] = make_msgid()
|
||||||
|
|
||||||
|
msg.set_content(self._render_template_text(notification))
|
||||||
|
msg.add_alternative(self._render_template_html(notification), subtype='html')
|
||||||
|
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
class BirdEmailServiceImpl(EmailService):
|
||||||
|
def send_email(self, email: EmailMessage):
|
||||||
|
# TODO: Implement Bird Specific Email sending and Error handling
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_service(config: EmailConfig) -> EmailService:
|
||||||
|
return BirdEmailServiceImpl(config)
|
||||||
12
src/notifications/service.py
Normal file
12
src/notifications/service.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from typing import Any
|
||||||
|
from .data import Notification
|
||||||
|
import abc
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationService(abc.ABC):
|
||||||
|
"""A service that sends notifications to the user"""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def notify(self, user: Any, notification: Notification):
|
||||||
|
"""Send a notification"""
|
||||||
|
pass
|
||||||
Loading…
Add table
Add a link
Reference in a new issue