Hi,
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.
Questions:
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.
Thoughts?
Kevin
---
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:
try:
@@ -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(
+ host=CONFIG.smtp.host,
+ port=CONFIG.smtp.port,
+ local_hostname=CONFIG.smtp.local_hostname,
+ )
+ conn.ehlo()
+ if CONFIG.smtp.type == _SMTPType.STARTTLS:
+ conn.starttls()
+ conn.ehlo()
+ 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:
+ return
+ with smtp:
+ try:
+ # 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"
+ msg.set_content("\n".join(lines))
+ logger.info(f"sending notification email to {config.CONFIG.smtp.addr_to}")
+ smtp.send_message(msg)
+ except:
+ 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
--
2.35.1