Add the bones of an Email Notification service

This commit is contained in:
Campbell Alden 2026-07-30 23:48:10 +09:00
parent a7fdd6cbd0
commit 24bb9dae30
8 changed files with 138 additions and 8 deletions

View file

@ -4,4 +4,8 @@
"logging": { "logging": {
"level": "info" "level": "info"
} }
"email": {
"api_key": "asdf1234",
"sender": "me@example.com"
}
} }

View file

@ -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
View 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'])

View file

@ -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)

View file

38
src/notifications/data.py Normal file
View 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

View 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)

View 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