[WIP] enable email notifications to site owner

Message ID
DKIM signature
Download raw message
Patch: +94 -0

This patch enables email notifications to the site owner.  I've been
running it on my instance for the past week or so, and have found it
useful.  It's rudimentary and not ready for merging, but I wanted to get
a temperature check on the feature to determine whether I should carry
the patch locally for my own personal use, or polish it up and propose
it for merging.


1. Are email notifications a desired feature?

2. Is this approach (hooking sqlalchemy `Notification` insert)
   appropriate, or should it be done e.g. manually after each
   `Notification` creation?  In a background worker only picking up
   notifications where `is_new=True`?  Somewhere else?

3. How much info / what format should the mails contain?  Just a link to
   /admin/notifications?  Full notification details in plain text?  MIME
   multipart HTML&text?

I briefly attempted to re-use the HTML in notifications.html for the
message body, but ran into a couple of problems.  First,
`get_actors_metadata` takes an async db context rather than the
synchronous one provided in the sqlalchemy insert hook, so I had trouble
populating `actors_metadata` for the template.  Second, the templates
use fastapi functions like `url_for`, and there was no straightforward
way to provide them in a non-HTTP-request context.  Neither issue is
insurmountable, but for now the emails are untemplated, text only, and
include no information about the notification except its type.



 app/config.py              | 51 ++++++++++++++++++++++++++++++++++++++
 app/incoming_activities.py |  1 +
 app/main.py                |  1 +
 app/notification_email.py  | 40 ++++++++++++++++++++++++++++++
 app/outgoing_activities.py |  1 +
 5 files changed, 94 insertions(+)
 create mode 100644 app/notification_email.py

diff --git a/app/config.py b/app/config.py
index 54bd4e1..0ddcc1f 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,7 +1,9 @@
import enum
import hashlib
import hmac
import os
import secrets
import smtplib
from pathlib import Path

import bcrypt
@@ -84,6 +86,24 @@ class _BlockedServer(pydantic.BaseModel):
    reason: str | None = None

class _SMTPType(enum.Enum):
    PLAIN = "plain"
    STARTTLS = "starttls"
    SSL = "ssl"
    LMTP = "lmtp"

class _SMTPConfig(pydantic.BaseModel):
    host: str
    port: int = 587
    local_hostname: str | None = None
    type: _SMTPType = _SMTPType.STARTTLS
    username: str | None = None
    password: str | None = None
    addr_from: str
    addr_to: str

class Config(pydantic.BaseModel):
    domain: str
    username: str
@@ -121,6 +141,8 @@ class Config(pydantic.BaseModel):
    # Only set when the app is served on a non-root path
    id: str | None = None

    smtp: _SMTPConfig | None = None

def load_config() -> Config:
@@ -262,3 +284,32 @@ def verify_csrf_token(

def hmac_sha256() -> hmac.HMAC:
    return hmac.new(CONFIG.secret.encode(), digestmod=hashlib.sha256)

def smtp_new() -> smtplib.SMTP | smtplib.SMTP_SSL | smtplib.LMTP | None:
    Returns a new EHLO'd and LOGIN'd smtplib.SMTP client session object,
    or None if unconfigured.

    Callers should make sure the session is closed when done, either by
    calling .close() or using a with statement.
    if not CONFIG.smtp:
        return None
    cls = smtplib.SMTP
    if CONFIG.smtp.type == _SMTPType.SSL:
        cls = smtplib.SMTP_SSL
    elif CONFIG.smtp.type == _SMTPType.LMTP:
        cls = smtplib.LMTP
    conn = cls(
    if CONFIG.smtp.type == _SMTPType.STARTTLS:
    if CONFIG.smtp.username:
        conn.login(CONFIG.smtp.username, CONFIG.smtp.password)
    return conn
diff --git a/app/incoming_activities.py b/app/incoming_activities.py
index 583b208..b2b8e3c 100644
--- a/app/incoming_activities.py
+++ b/app/incoming_activities.py
@@ -11,6 +11,7 @@ from app import activitypub as ap
from app import httpsig
from app import ldsig
from app import models
from app import notification_email
from app.boxes import save_to_inbox
from app.database import AsyncSession
from app.utils.datetime import now
diff --git a/app/main.py b/app/main.py
index d0063dc..169ce22 100644
--- a/app/main.py
+++ b/app/main.py
@@ -50,6 +50,7 @@ from app import httpsig
from app import indieauth
from app import media
from app import micropub
from app import notification_email
from app import models
from app import templates
from app import webmentions
diff --git a/app/notification_email.py b/app/notification_email.py
new file mode 100644
index 0000000..dd594b6
--- /dev/null
+++ b/app/notification_email.py
@@ -0,0 +1,40 @@
from email.message import EmailMessage

from loguru import logger
from sqlalchemy import event

from app import config
from app import models
from app import templates
from app.actor import get_actors_metadata

@event.listens_for(models.Notification, "after_insert")
def handle_notification(mapper, db_session, notif):
    smtp = config.smtp_new()
    if not smtp:
    with smtp:
            # TODO(doof): use templates instead of ad-hoc body assembly
            lines = [f"Type: {notif.notification_type}"]
            if notif.actor:
                lines.append(f"Actor: {notif.actor.ap_id}")
            if notif.outbox_object:
                lines.append(f"Outbox object: {notif.outbox_object.ap_id}")
            if notif.inbox_object:
                lines.append(f"Inbox object: {notif.inbox_object.ap_id}")
            if notif.webmention:
                lines.append(f"Webmention source: {notif.webmention.source}")
            msg = EmailMessage()
            msg["From"] = config.CONFIG.smtp.addr_from
            msg["To"] = config.CONFIG.smtp.addr_to
            msg["Subject"] = f"{notif.notification_type} notification"
            logger.info(f"sending notification email to {config.CONFIG.smtp.addr_to}")
            import traceback

            exc = traceback.format_exc()
            logger.info(f"failed to send notification email: {exc}")
diff --git a/app/outgoing_activities.py b/app/outgoing_activities.py
index 1d992a9..455ce81 100644
--- a/app/outgoing_activities.py
+++ b/app/outgoing_activities.py
@@ -17,6 +17,7 @@ from app import activitypub as ap
from app import config
from app import ldsig
from app import models
from app import notification_email
from app.actor import LOCAL_ACTOR
from app.actor import _actor_hash
from app.config import KEY_PATH
Reply to thread Export thread (mbox)